mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-10-09 04:42:30 -04:00
changes for <template> content
Template content belongs to an inert "template contents owner document" with no browsing context, related changes for #3328.
This commit is contained in:
7 files changed
+182
-31
No files matched your search
@@ -3295,10 +3295,13 @@ fn nodeIsReady(self: *Frame, comptime from_parser: bool, node: *Node) !void {
|
||||
// Scripts, iframes, links and styles activate on becoming connected;
|
||||
// appending them to a detached parent does nothing (they run/load later
|
||||
// if the subtree gets inserted into the document).
|
||||
if (comptime from_parser == false) {
|
||||
switch (node._type) {
|
||||
.element => if (!node.isConnected()) return,
|
||||
else => {},
|
||||
if (node._type == .element) {
|
||||
if (comptime from_parser) {
|
||||
if (node.getDocument(self)._frame == null) {
|
||||
return;
|
||||
}
|
||||
} else if (!node.isConnected()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ const String = lp.String;
|
||||
const IFrame = Element.Html.IFrame;
|
||||
|
||||
pub fn createElementNS(document: *const Node.Document, namespace: Element.Namespace, name: []const u8, attribute_iterator: anytype) !*Node {
|
||||
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator;
|
||||
const from_parser = @TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes;
|
||||
const from_clone = @TypeOf(attribute_iterator) == *Element.Attribute.List or @TypeOf(attribute_iterator) == *const Element.Attribute.List;
|
||||
const frame = frameOf(document);
|
||||
|
||||
@@ -849,6 +849,7 @@ pub fn createElementNS(document: *const Node.Document, namespace: Element.Namesp
|
||||
const node = try createHtmlElementT(document, Element.Html.Custom, namespace, attribute_iterator, .{
|
||||
._tag_name = tag_name,
|
||||
._definition = definition,
|
||||
._upgrade_candidate = creation == .construct,
|
||||
});
|
||||
if (creation == .construct) {
|
||||
try realm._undefined_custom_elements.append(realm.arena, node.as(Element).is(Element.Html.Custom).?);
|
||||
@@ -1070,7 +1071,7 @@ fn createHtmlElementT(document: *const Node.Document, comptime E: type, namespac
|
||||
|
||||
const node = element.asNode();
|
||||
if (@hasDecl(E, "Build") and @hasDecl(E.Build, "created")) {
|
||||
if (comptime @TypeOf(attribute_iterator) == Parser.AttributeIterator and @hasDecl(E.Build, "parser_created_on_insert")) {
|
||||
if (comptime (@TypeOf(attribute_iterator) == Parser.AttributeIterator or @TypeOf(attribute_iterator) == Parser.RebuiltAttributes) and @hasDecl(E.Build, "parser_created_on_insert")) {
|
||||
// The element wants its parent, it'll do this work when inserted.
|
||||
return node;
|
||||
}
|
||||
@@ -1120,6 +1121,13 @@ fn populateElementAttributes(frame: *Frame, element: *Element, list: anytype) !v
|
||||
return;
|
||||
}
|
||||
|
||||
if (@TypeOf(list) == Parser.RebuiltAttributes) {
|
||||
// from the parser, rebuilding an element in another document
|
||||
try element._attributes.cloneFrom(list.list, frame);
|
||||
element.noteStyleAttribute();
|
||||
return;
|
||||
}
|
||||
|
||||
// from the parser
|
||||
if (@TypeOf(list) == @TypeOf(null)) {
|
||||
return;
|
||||
|
||||
+124
-22
@@ -42,6 +42,17 @@ pub const ParsedNode = struct {
|
||||
// html5ever should never ask us for this data on a non-element, and we'll
|
||||
// assert that, with this optional, to make sure our assumption is correct.
|
||||
data: ?*anyopaque,
|
||||
|
||||
// Set once html5ever has put the node in the tree or given it children.
|
||||
// Until then the node is unobservable and its document is only a guess.
|
||||
placed: bool = false,
|
||||
};
|
||||
|
||||
// Attributes of an element the parser rebuilds (see settleDocument): the ones
|
||||
// html5ever gave the original, read back from it. node_factory treats this
|
||||
// like AttributeIterator, i.e. as a creation by the parser.
|
||||
pub const RebuiltAttributes = struct {
|
||||
list: *const Element.Attribute.List,
|
||||
};
|
||||
|
||||
// html5ever's tokenizer flushes the script-data character buffer on every '<'
|
||||
@@ -63,6 +74,13 @@ err: ?Error,
|
||||
arena: Allocator,
|
||||
container: ParsedNode,
|
||||
document: *Node.Document,
|
||||
// The document new nodes are created in. The spec creates an element for a
|
||||
// token in its intended parent's document, and a <template>'s content belongs
|
||||
// to the inert template contents owner document (Document.templateContentsOwner)
|
||||
// where custom elements are never constructed and nothing runs or loads.
|
||||
// html5ever creates an element before it says where it goes, so this is the
|
||||
// document of the last insertion point it did tell us about.
|
||||
creation_document: *Node.Document,
|
||||
strings: std.StringHashMapUnmanaged(void),
|
||||
pending_text: ?PendingText,
|
||||
// One buffer reused across every text run in this parser. clearRetainingCapacity
|
||||
@@ -98,15 +116,18 @@ pub const Options = struct {
|
||||
};
|
||||
|
||||
pub fn init(arena: Allocator, node: *Node, frame: *Frame, opts: Options) Parser {
|
||||
const document = node.getDocument(frame);
|
||||
return .{
|
||||
.err = null,
|
||||
.frame = frame,
|
||||
.document = node.getDocument(frame),
|
||||
.document = document,
|
||||
.creation_document = document,
|
||||
.strings = .empty,
|
||||
.arena = arena,
|
||||
.container = ParsedNode{
|
||||
.data = null,
|
||||
.node = node,
|
||||
.placed = true,
|
||||
},
|
||||
.pending_text = null,
|
||||
.buf = .empty,
|
||||
@@ -161,7 +182,7 @@ fn appendTextChunk(self: *Parser, parent: *Node, txt: []const u8) !void {
|
||||
|
||||
// Fresh text run: the first chunk lives on _data only. buf stays empty
|
||||
// until (and unless) a second chunk arrives.
|
||||
const new_text = try Frame.node_factory.createTextNode(self.document, txt);
|
||||
const new_text = try Frame.node_factory.createTextNode(parent.getDocument(self.frame), txt);
|
||||
try self.frame.appendNew(parent, new_text);
|
||||
self.inserted_since_checkpoint +|= 1;
|
||||
self.pending_text = .{
|
||||
@@ -530,7 +551,7 @@ fn _createElementCallback(self: *Parser, data: *anyopaque, qname: h5e.QualName,
|
||||
} else local;
|
||||
const namespace_string = qname.ns.slice();
|
||||
const namespace = if (namespace_string.len == 0) default_namespace else Element.Namespace.parse(namespace_string);
|
||||
const node = try Frame.node_factory.createElementNS(self.document, namespace, name, attributes);
|
||||
const node = try Frame.node_factory.createElementNS(self.creation_document, namespace, name, attributes);
|
||||
if (namespace == .unknown and namespace_string.len > 0) {
|
||||
// Same as Document.createElementNS: keep the URI so namespaceURI and
|
||||
// lookupNamespaceURI can return it.
|
||||
@@ -556,7 +577,7 @@ fn createCommentCallback(ctx: *anyopaque, str: h5e.StringSlice) callconv(.c) ?*a
|
||||
};
|
||||
}
|
||||
fn _createCommentCallback(self: *Parser, str: []const u8) !*anyopaque {
|
||||
const node = try Frame.node_factory.createComment(self.document, str);
|
||||
const node = try Frame.node_factory.createComment(self.creation_document, str);
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
@@ -575,7 +596,7 @@ fn createProcessingInstruction(ctx: *anyopaque, target: h5e.StringSlice, data: h
|
||||
};
|
||||
}
|
||||
fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const u8) !*anyopaque {
|
||||
const node = try Frame.node_factory.createProcessingInstruction(self.document, target, data);
|
||||
const node = try Frame.node_factory.createProcessingInstruction(self.creation_document, target, data);
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
@@ -649,11 +670,15 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
|
||||
const template = element.subtype(Element.Html).is(Element.Html.Template) orelse unreachable;
|
||||
const content_node = template.getContent().asNode();
|
||||
|
||||
// html5ever asks for this to insert there next.
|
||||
self.creation_document = content_node.getDocument(self.frame);
|
||||
|
||||
// Create a ParsedNode wrapper for the content DocumentFragment
|
||||
const pn = try self.arena.create(ParsedNode);
|
||||
pn.* = .{
|
||||
.data = null,
|
||||
.node = content_node,
|
||||
.placed = true,
|
||||
};
|
||||
return pn;
|
||||
}
|
||||
@@ -708,11 +733,11 @@ fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.Nod
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendCallback(getNode(parent_ref), node_or_text) catch |err| {
|
||||
self._appendCallback(getParsed(parent_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append };
|
||||
};
|
||||
}
|
||||
fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
fn _appendCallback(self: *Parser, parent_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
// child node is guaranteed not to belong to another parent
|
||||
switch (node_or_text.toUnion()) {
|
||||
.node => |cpn| {
|
||||
@@ -722,7 +747,8 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
|
||||
try self.flushPendingText();
|
||||
self.maybeCheckpoint();
|
||||
self.inserted_since_checkpoint +|= 1;
|
||||
const child = getNode(cpn);
|
||||
const child = try self.settleDocument(parent_pn, getParsed(cpn));
|
||||
const parent = parent_pn.node;
|
||||
if (child._parent) |previous_parent| {
|
||||
// html5ever says this can't happen, but we might be screwing up
|
||||
// the node on our side. We shouldn't be, but we're seeing this
|
||||
@@ -736,11 +762,66 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
|
||||
}
|
||||
try self.frame.appendNew(parent, child);
|
||||
},
|
||||
.text => |txt| try self.appendTextChunk(parent, txt),
|
||||
.text => |txt| {
|
||||
const parent = parent_pn.node;
|
||||
self.creation_document = parent.getDocument(self.frame);
|
||||
try self.appendTextChunk(parent, txt);
|
||||
},
|
||||
.failed => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn settleDocument(self: *Parser, parent_pn: *ParsedNode, child_pn: *ParsedNode) !*Node {
|
||||
const frame = self.frame;
|
||||
const parent = parent_pn.node;
|
||||
const child = child_pn.node;
|
||||
|
||||
// A custom element constructor may have put the child in the tree
|
||||
// itself; that is as observable as being placed by html5ever.
|
||||
if (child._parent != null) {
|
||||
child_pn.placed = true;
|
||||
}
|
||||
|
||||
var document = parent.getDocument(frame);
|
||||
const child_document = child.getDocument(frame);
|
||||
if (child_document != document) {
|
||||
if (child_pn.placed == false) {
|
||||
child_pn.node = try self.rebuildIn(child, document);
|
||||
} else if (parent_pn.placed == false) {
|
||||
try frame.adoptNodeTree(parent, document, child_document);
|
||||
document = child_document;
|
||||
} else {
|
||||
try frame.adoptNodeTree(child, child_document, document);
|
||||
}
|
||||
}
|
||||
|
||||
parent_pn.placed = true;
|
||||
child_pn.placed = true;
|
||||
self.creation_document = document;
|
||||
return child_pn.node;
|
||||
}
|
||||
|
||||
// A copy of the never-placed `node` in `document`, created like the parser
|
||||
// created `node`, in the wrong document.
|
||||
fn rebuildIn(self: *Parser, node: *Node, document: *Node.Document) !*Node {
|
||||
const frame = self.frame;
|
||||
const element = node.is(Element) orelse {
|
||||
// A comment or processing instruction: nothing to construct or load.
|
||||
try frame.adoptNodeTree(node, node.getDocument(frame), document);
|
||||
return node;
|
||||
};
|
||||
|
||||
const copy = try Frame.node_factory.createElementNS(document, element._namespace, element.getTagNameDump(), RebuiltAttributes{ .list = &element._attributes });
|
||||
if (element._namespace == .unknown) {
|
||||
// The URI lives in a side table, see _createElementCallback.
|
||||
const page = self.document._page;
|
||||
if (page.element_namespace_uris.fetchRemove(element)) |entry| {
|
||||
try page.element_namespace_uris.put(page.frame_arena, copy.as(Element), entry.value);
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
|
||||
const self: *Parser = @ptrCast(@alignCast(ctx));
|
||||
if (self.terminated) {
|
||||
@@ -769,15 +850,28 @@ fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_re
|
||||
}
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| {
|
||||
self._reparentChildrenCallback(getParsed(node_ref), getParsed(new_parent_ref)) catch |err| {
|
||||
self.err = .{ .err = err, .source = .reparent_children };
|
||||
};
|
||||
}
|
||||
fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !void {
|
||||
fn _reparentChildrenCallback(self: *Parser, node_pn: *ParsedNode, new_parent_pn: *ParsedNode) !void {
|
||||
// Reparenting can move the pending text node out from under us — the
|
||||
// node's _parent changes but pending_text.parent does not. Flush so the
|
||||
// accumulator commits before the tree is rearranged.
|
||||
try self.flushPendingText();
|
||||
|
||||
// The new parent is a fresh element (adoption agency algorithm); the
|
||||
// children it takes settle its document, not the other way around.
|
||||
const node = node_pn.node;
|
||||
const new_parent = new_parent_pn.node;
|
||||
const document = node.getDocument(self.frame);
|
||||
if (new_parent_pn.placed == false) {
|
||||
const new_parent_document = new_parent.getDocument(self.frame);
|
||||
if (new_parent_document != document) {
|
||||
try self.frame.adoptNodeTree(new_parent, new_parent_document, document);
|
||||
}
|
||||
new_parent_pn.placed = true;
|
||||
}
|
||||
try self.frame.appendAllChildren(node, new_parent);
|
||||
}
|
||||
|
||||
@@ -789,19 +883,21 @@ fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| {
|
||||
self._appendBeforeSiblingCallback(getParsed(sibling_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append_before_sibling };
|
||||
};
|
||||
}
|
||||
fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
fn _appendBeforeSiblingCallback(self: *Parser, sibling_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
// Foster parenting / before-sibling insertions interrupt any pending text
|
||||
// run (the new node lands at a different position from the pending text's
|
||||
// tail). Flush before reading the parent's structure.
|
||||
try self.flushPendingText();
|
||||
const sibling = sibling_pn.node;
|
||||
const parent = sibling.parentNode() orelse return error.NoParent;
|
||||
const node: *Node = switch (node_or_text.toUnion()) {
|
||||
.node => |cpn| blk: {
|
||||
const child = getNode(cpn);
|
||||
var parent_pn = ParsedNode{ .node = parent, .data = null, .placed = true };
|
||||
const child = try self.settleDocument(&parent_pn, getParsed(cpn));
|
||||
if (child._parent) |previous_parent| {
|
||||
// A custom element constructor may have inserted the node into the
|
||||
// DOM before the parser officially places it (e.g. via foster
|
||||
@@ -810,7 +906,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
|
||||
}
|
||||
break :blk child;
|
||||
},
|
||||
.text => |txt| try Frame.node_factory.createTextNode(self.document, txt),
|
||||
.text => |txt| blk: {
|
||||
self.creation_document = parent.getDocument(self.frame);
|
||||
break :blk try Frame.node_factory.createTextNode(self.creation_document, txt);
|
||||
},
|
||||
.failed => return,
|
||||
};
|
||||
try self.frame.insertNodeRelative(parent, node, .{ .before = sibling }, .{});
|
||||
@@ -824,21 +923,24 @@ fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, pre
|
||||
|
||||
const cp = self.frame._ce_reactions.push();
|
||||
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
|
||||
self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| {
|
||||
self._appendBasedOnParentNodeCallback(getParsed(element_ref), getParsed(prev_element_ref), node_or_text) catch |err| {
|
||||
self.err = .{ .err = err, .source = .append_based_on_parent_node };
|
||||
};
|
||||
}
|
||||
fn _appendBasedOnParentNodeCallback(self: *Parser, element: *Node, prev_element: *Node, node_or_text: h5e.NodeOrText) !void {
|
||||
if (element.parentNode()) |_| {
|
||||
try self._appendBeforeSiblingCallback(element, node_or_text);
|
||||
fn _appendBasedOnParentNodeCallback(self: *Parser, element_pn: *ParsedNode, prev_element_pn: *ParsedNode, node_or_text: h5e.NodeOrText) !void {
|
||||
if (element_pn.node.parentNode()) |_| {
|
||||
try self._appendBeforeSiblingCallback(element_pn, node_or_text);
|
||||
} else {
|
||||
try self._appendCallback(prev_element, node_or_text);
|
||||
try self._appendCallback(prev_element_pn, node_or_text);
|
||||
}
|
||||
}
|
||||
|
||||
fn getParsed(ref: *anyopaque) *ParsedNode {
|
||||
return @ptrCast(@alignCast(ref));
|
||||
}
|
||||
|
||||
fn getNode(ref: *anyopaque) *Node {
|
||||
const pn: *ParsedNode = @ptrCast(@alignCast(ref));
|
||||
return pn.node;
|
||||
return getParsed(ref).node;
|
||||
}
|
||||
|
||||
fn asUint(comptime string: anytype) std.meta.Int(
|
||||
|
||||
@@ -186,6 +186,10 @@ fn upgradeNode(self: *CustomElementRegistry, node: *Node, frame: *Frame) !void {
|
||||
}
|
||||
|
||||
fn upgradeElement(self: *CustomElementRegistry, element: *Element, frame: *Frame) !void {
|
||||
if (element.asNode().getDocument(frame)._frame == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const custom = element.is(Custom) orelse {
|
||||
return Custom.checkAndAttachBuiltIn(element, frame);
|
||||
};
|
||||
|
||||
@@ -58,6 +58,7 @@ _proto: *Node,
|
||||
_page: *Page,
|
||||
_index: u32, // browser.documents index
|
||||
_frame: ?*Frame = null,
|
||||
_template_contents_owner: ?*Document = null,
|
||||
_url: ?[:0]const u8 = null, // URL for documents created via DOMImplementation (about:blank)
|
||||
// content type override for documents created via DOMImplementation.createDocument
|
||||
_content_type: ?[]const u8 = null,
|
||||
@@ -517,6 +518,32 @@ fn createDocumentFragment(self: *Document, frame: *Frame) !*Node.DocumentFragmen
|
||||
return Node.DocumentFragment.init(self, frame);
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/scripting.html#appropriate-template-contents-owner-document
|
||||
// A <template>'s content lives in a document with no browsing context, shared
|
||||
// by every template of this document (and by the templates nested in that
|
||||
// content). Nothing in there is connected, scripts never run and, having no
|
||||
// custom element registry, custom elements are never constructed.
|
||||
//
|
||||
// Only a copy stamped into a real document gets upgraded.
|
||||
pub fn templateContentsOwner(self: *Document, frame: *Frame) !*Document {
|
||||
if (self._template_contents_owner) |owner| {
|
||||
return owner;
|
||||
}
|
||||
|
||||
const owner: *Document = if (self._type == .html)
|
||||
(try frame._factory.document(HTMLDocument{ ._proto = undefined })).asDocument()
|
||||
else
|
||||
try frame._factory.genericDocument(.{});
|
||||
owner._url = "about:blank";
|
||||
owner._charset = "UTF-8";
|
||||
owner._ready_state = .complete;
|
||||
// Its own templates' content stays in it.
|
||||
owner._template_contents_owner = owner;
|
||||
|
||||
self._template_contents_owner = owner;
|
||||
return owner;
|
||||
}
|
||||
|
||||
pub fn createComment(self: *Document, data: []const u8) !*Node {
|
||||
return Frame.node_factory.createComment(self, data);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ _connected_callback_invoked: bool = false,
|
||||
_disconnected_callback_invoked: bool = false,
|
||||
_upgrade_failed: bool = false, // a failed upgrade is never retried
|
||||
_upgrade_in_progress: bool = false,
|
||||
_upgrade_candidate: bool = false, // listed in a frame's _undefined_custom_elements
|
||||
|
||||
pub fn asElement(self: *Custom) *Element {
|
||||
return Factory.protoOf(self).asElement();
|
||||
@@ -85,7 +86,11 @@ pub fn enqueueConnectedCallbackOnElement(comptime from_parser: bool, element: *E
|
||||
CustomElementRegistry.upgradeCustomElement(custom, definition, frame) catch {};
|
||||
return;
|
||||
}
|
||||
// Element is undefined and no definition exists yet — nothing to queue.
|
||||
|
||||
if (!custom._upgrade_candidate) {
|
||||
custom._upgrade_candidate = true;
|
||||
try frame._undefined_custom_elements.append(frame.arena, custom);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,8 +157,10 @@ pub const JsApi = struct {
|
||||
pub const Build = struct {
|
||||
pub fn created(node: *Node, frame: *Frame) !void {
|
||||
const self = node.as(Template);
|
||||
// Create the template content DocumentFragment
|
||||
self._content = try DocumentFragment.init(node.getDocument(frame), frame);
|
||||
// The content DocumentFragment belongs to the inert template contents
|
||||
// owner document, not to the template's own document.
|
||||
const owner = try node.getDocument(frame).templateContentsOwner(frame);
|
||||
self._content = try DocumentFragment.init(owner, frame);
|
||||
}
|
||||
|
||||
// Per the HTML spec's cloning steps for <template>, a deep clone must
|
||||
|
||||
Reference in new issue
Block a user