Skip to content

Commit

Permalink
feat: HTMLTemplateElement (fixes #18)
Browse files Browse the repository at this point in the history
  • Loading branch information
b-fuze committed May 8, 2022
1 parent 29c9e1f commit 05369e4
Show file tree
Hide file tree
Showing 10 changed files with 336 additions and 77 deletions.
2 changes: 1 addition & 1 deletion build/deno-wasm/deno-wasm.js

Large diffs are not rendered by default.

Binary file modified build/deno-wasm/deno-wasm_bg.wasm
Binary file not shown.
47 changes: 27 additions & 20 deletions html-parser/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,38 +106,45 @@ fn serialize_node(buf: &mut Vec<u8>, dom: &Rc<Node>) {
NodeData::Element {
ref name,
ref attrs,
ref template_contents,
..
} => {
write!(&mut *buf, "[1,").unwrap();
serde_json::to_writer(&mut *buf, &name.local).unwrap();
write!(&mut *buf, ",").unwrap();
serialize_element_attributes(buf, attrs);

let children = dom.children.borrow();
if let Some(contents) = template_contents {
buf.push(b',');
// Include <template> contents
serialize_node(&mut *buf, &contents);
} else {
let children = dom.children.borrow();

let mut last_child_rendered = true;
for child in children.iter() {
if last_child_rendered {
// assume something will be written
buf.push(b',');
}
let mut last_child_rendered = true;
for child in children.iter() {
if last_child_rendered {
// assume something will be written
buf.push(b',');
}

let child_rendered = {
let len_before = buf.len();
serialize_node(&mut *buf, child);
let len_after = buf.len();
let child_rendered = {
let len_before = buf.len();
serialize_node(&mut *buf, child);
let len_after = buf.len();

len_after - len_before > 0
};
len_after - len_before > 0
};

last_child_rendered = child_rendered;
}
last_child_rendered = child_rendered;
}

if !last_child_rendered {
// remove comma that was written if it turns out that
// nothing was written by the last child
// (or at least, nothing was written since the last comma)
buf.pop().unwrap();
if !last_child_rendered {
// remove comma that was written if it turns out that
// nothing was written by the last child
// (or at least, nothing was written since the last comma)
buf.pop().unwrap();
}
}

write!(&mut *buf, "]").unwrap();
Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./dom/element.ts";
export * from "./dom/document.ts";
export * from "./dom/document-fragment.ts";
export * from "./dom/dom-parser.ts";
export * from "./dom/elements/html-template-element.ts";
export { disableCodeGeneration as denoDomDisableQuerySelectorCodeGeneration } from "./dom/selectors/selectors.ts";

// Re-export private constructors without constructor signature
Expand Down
23 changes: 23 additions & 0 deletions src/deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { parse, parseFrag } from "./parser.ts";
import { CTOR_KEY } from "./constructor-lock.ts";
import { Comment, Node, NodeType, Text } from "./dom/node.ts";
import { DocumentType } from "./dom/document.ts";
import { DocumentFragment } from "./dom/document-fragment.ts";
import { HTMLTemplateElement } from "./dom/elements/html-template-element.ts";
import { Element } from "./dom/element.ts";

export function nodesFromString(html: string): Node {
Expand All @@ -22,6 +24,27 @@ function nodeFromArray(data: any, parentNode: Node | null): Node {
// For reference only:
// type node = [NodeType, nodeName, attributes, node[]]
// | [NodeType, characterData]

// <template> element gets special treatment, until
// we implement all the HTML elements
if (data[1] === "template") {
const content = nodeFromArray(data[3], null);
const contentFrag = new DocumentFragment();
const fragMutator = contentFrag._getChildNodesMutator();

for (const child of content.childNodes) {
fragMutator.push(child);
child._setParent(contentFrag);
}

return new HTMLTemplateElement(
parentNode,
data[2],
CTOR_KEY,
contentFrag,
);
}

const elm = new Element(data[1], parentNode, data[2], CTOR_KEY);
const childNodes = elm._getChildNodesMutator();
let childNode: Node;
Expand Down
62 changes: 7 additions & 55 deletions src/dom/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { fragmentNodesFromString } from "../deserialize.ts";
import { Comment, Node, nodesAndTextNodes, NodeType, Text } from "./node.ts";
import { NodeList, nodeListMutatorSym } from "./node-list.ts";
import { HTMLCollection } from "./html-collection.ts";
import { getElementsByClassName } from "./utils.ts";
import {
getElementAttributesString,
getElementsByClassName,
getInnerHtmlFromNodes,
} from "./utils.ts";
import UtilTypes from "./utils-types.ts";

export class DOMTokenList extends Set<string> {
Expand Down Expand Up @@ -180,22 +184,9 @@ export class Element extends Node {

get outerHTML(): string {
const tagName = this.tagName.toLowerCase();
const attributes = this.attributes;
let out = "<" + tagName;

for (const attribute of this.getAttributeNames()) {
out += ` ${attribute.toLowerCase()}`;

// escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString
if (attributes[attribute] != null) {
out += `="${
attributes[attribute]
.replace(/&/g, "&amp;")
.replace(/\xA0/g, "&nbsp;")
.replace(/"/g, "&quot;")
}"`;
}
}
out += getElementAttributesString(this.attributes);

// Special handling for void elements
switch (tagName) {
Expand Down Expand Up @@ -229,46 +220,7 @@ export class Element extends Node {
}

get innerHTML(): string {
let out = "";

for (const child of this.childNodes) {
switch (child.nodeType) {
case NodeType.ELEMENT_NODE:
out += (child as Element).outerHTML;
break;

case NodeType.COMMENT_NODE:
out += `<!--${(child as Comment).data}-->`;
break;

case NodeType.TEXT_NODE:
// Special handling for rawtext-like elements.
switch (this.tagName.toLowerCase()) {
case "style":
case "script":
case "xmp":
case "iframe":
case "noembed":
case "noframes":
case "plaintext":
out += (child as Text).data;
break;

default:
// escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString
out += (child as Text).data
.replace(/&/g, "&amp;")
.replace(/\xA0/g, "&nbsp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
break;
}

break;
}
}

return out;
return getInnerHtmlFromNodes(this.childNodes, this.tagName);
}

set innerHTML(html: string) {
Expand Down
86 changes: 86 additions & 0 deletions src/dom/elements/html-template-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Node } from "../node.ts";
import { Element } from "../element.ts";
import { Document } from "../document.ts";
import { DocumentFragment } from "../document-fragment.ts";
import { getElementAttributesString, getInnerHtmlFromNodes } from "../utils.ts";
import { fragmentNodesFromString } from "../../deserialize.ts";
import { CTOR_KEY } from "../../constructor-lock.ts";

export class HTMLTemplateElement extends Element {
/**
* This blocks access to the .#contents property when the
* super() constructor is running which invokes (our
* overridden) _setParent() method. Without it, we get
* the following error thrown:
*
* TypeError: Cannot read private member #content from
* an object whose class did not declare it
*
* FIXME: Maybe find a cleaner way to do this
*/
private __contentIsSet = false;
#content: DocumentFragment | null = null;

constructor(
parentNode: Node | null,
attributes: [string, string][],
key: typeof CTOR_KEY,
content: DocumentFragment,
) {
super(
"TEMPLATE",
parentNode,
attributes,
key,
);

this.#content = content;
this.__contentIsSet = true;
}

get content(): DocumentFragment {
return this.#content!;
}

override _setOwnerDocument(document: Document | null) {
super._setOwnerDocument(document);

if (this.__contentIsSet) {
this.content._setOwnerDocument(document);
}
}

get innerHTML(): string {
return getInnerHtmlFromNodes(this.content.childNodes, "template");
}

// Replace children in the `.content`
set innerHTML(html: string) {
const content = this.content;

// Remove all children
for (const child of content.childNodes) {
child._setParent(null);
}

const mutator = content._getChildNodesMutator();
mutator.splice(0, content.childNodes.length);

// Parse HTML into new children
if (html.length) {
const parsed = fragmentNodesFromString(html);
mutator.push(...parsed.childNodes[0].childNodes);

for (const child of content.childNodes) {
child._setParent(content);
child._setOwnerDocument(content.ownerDocument);
}
}
}

get outerHTML(): string {
return `<template${
getElementAttributesString(this.attributes)
}>${this.innerHTML}</template>`;
}
}
73 changes: 72 additions & 1 deletion src/dom/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Node, NodeType } from "./node.ts";
import { Comment, Node, NodeType, Text } from "./node.ts";
import { NodeList } from "./node-list.ts";
import UtilTypes from "./utils-types.ts";
import type { Element } from "./element.ts";
import type { DocumentFragment } from "./document-fragment.ts";
Expand Down Expand Up @@ -31,6 +32,76 @@ export function getElementsByClassName(
return search;
}

export function getInnerHtmlFromNodes(
nodes: NodeList,
tagName: string,
): string {
let out = "";

for (const child of nodes) {
switch (child.nodeType) {
case NodeType.ELEMENT_NODE:
out += (child as Element).outerHTML;
break;

case NodeType.COMMENT_NODE:
out += `<!--${(child as Comment).data}-->`;
break;

case NodeType.TEXT_NODE:
// Special handling for rawtext-like elements.
switch (tagName) {
case "style":
case "script":
case "xmp":
case "iframe":
case "noembed":
case "noframes":
case "plaintext":
out += (child as Text).data;
break;

default:
// escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString
out += (child as Text).data
.replace(/&/g, "&amp;")
.replace(/\xA0/g, "&nbsp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
break;
}

break;
}
}

return out;
}

// FIXME: This uses the incorrect .attributes implementation, it
// should probably be changed when .attributes is fixed
export function getElementAttributesString(
attributes: Record<string, string>,
): string {
let out = "";

for (const attribute of Object.keys(attributes)) {
out += ` ${attribute.toLowerCase()}`;

// escaping: https://html.spec.whatwg.org/multipage/parsing.html#escapingString
if (attributes[attribute] != null) {
out += `="${
attributes[attribute]
.replace(/&/g, "&amp;")
.replace(/\xA0/g, "&nbsp;")
.replace(/"/g, "&quot;")
}"`;
}
}

return out;
}

export function isDocumentFragment(node: Node): node is DocumentFragment {
let obj: any = node;

Expand Down
19 changes: 19 additions & 0 deletions test/units/Element-outerHTML.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { DOMParser } from "../../deno-dom-wasm.ts";
import { assertStrictEquals as assertEquals } from "https://deno.land/[email protected]/testing/asserts.ts";

// TODO: More comprehensive tests

Deno.test("Element.outerHTML", () => {
const doc = new DOMParser().parseFromString(
`
<button onclick=false data-foo="bar baz">Hi, <strong qux>there!</strong></button>
`,
"text/html",
)!;

const button = doc.querySelector("button")!;
assertEquals(
button.outerHTML,
`<button onclick="false" data-foo="bar baz">Hi, <strong qux="">there!</strong></button>`,
);
});
Loading

0 comments on commit 05369e4

Please sign in to comment.