Merge pull request #3181 from lightpanda-io/getHTML

webapi: Element/ShadowRoot getHTML
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-08-13 06:40:45 +08:00
commit b7868c455b
5 files changed
+289 -18

No files matched your search

+82 -13
View File
@@ -36,7 +36,7 @@ pub const Opts = struct {
invisible: bool = false,
};
pub const Shadow = enum {
pub const Shadow = union(enum) {
// Skip shadow DOM entirely (innerHTML/outerHTML)
skip,
@@ -45,6 +45,16 @@ pub const Opts = struct {
// Resolve slot elements (like what actually gets rendered)
rendered,
// Element/ShadowRoot.getHTML can control how it handles shadow elements
declarative: Declarative,
pub const Declarative = struct {
// Serialize shadow roots whose `serializable` flag is set
serializable_shadow_roots: bool = false,
// Serialize these roots regardless of their flags or mode
shadow_roots: []const *Node.ShadowRoot = &.{},
};
};
};
@@ -125,20 +135,30 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
return writer.writeAll("</slot>");
}
}
if (opts.shadow != .skip) {
if (frame._element_shadow_roots.get(el)) |shadow| {
try children(shadow.asNode(), opts, writer, frame);
// In rendered mode, light DOM is only shown through slots, not directly
if (opts.shadow == .rendered) {
// Skip rendering light DOM children
if (!isVoidElement(el)) {
try writer.writeAll("</");
try writer.writeAll(el.getTagNameDump());
try writer.writeByte('>');
switch (opts.shadow) {
.skip => {},
.complete, .rendered => {
if (frame._element_shadow_roots.get(el)) |shadow| {
try children(shadow.asNode(), opts, writer, frame);
// In rendered mode, light DOM is only shown through slots, not directly
if (opts.shadow == .rendered) {
// Skip rendering light DOM children
if (!isVoidElement(el)) {
try writer.writeAll("</");
try writer.writeAll(el.getTagNameDump());
try writer.writeByte('>');
}
return;
}
return;
}
}
},
.declarative => |declarative| {
if (frame._element_shadow_roots.get(el)) |shadow| {
if (shouldSerializeShadow(shadow, declarative)) {
try writeDeclarativeShadow(shadow, opts, writer, frame);
}
}
},
}
if (opts.with_frames and el.is(IFrame) != null) {
@@ -199,6 +219,22 @@ fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Wri
}
}
// Element.getHTML / ShadowRoot.getHTML
pub fn getHTML(node: *Node, declarative: Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
const opts = Opts{ .shadow = .{ .declarative = declarative } };
if (node.is(Node.Element)) |el| {
if (frame._element_shadow_roots.get(el)) |shadow| {
if (shouldSerializeShadow(shadow, declarative)) {
// if the element's shadowroot tree is rendered before its
// children (assume the opts say that it should serialize the
// shadowroot at all (i.e. shouldSerializeShadow).
try writeDeclarativeShadow(shadow, opts, writer, frame);
}
}
}
return children(node, opts, writer, frame);
}
pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void {
var it = parent.childrenIterator();
while (it.next()) |child| {
@@ -247,6 +283,39 @@ pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void {
try writer.endObject();
}
fn shouldSerializeShadow(shadow: *const Node.ShadowRoot, declarative: Opts.Shadow.Declarative) bool {
if (declarative.serializable_shadow_roots and shadow._serializable) {
return true;
}
for (declarative.shadow_roots) |sr| {
if (sr == shadow) {
// if it's explictly requested, it's serialized even if _serialized == false
return true;
}
}
return false;
}
// The spec's "attach a declarative shadow root" serialization: attribute order
// is fixed, and boolean attributes serialize with an explicit ="".
fn writeDeclarativeShadow(shadow: *Node.ShadowRoot, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void {
try writer.writeAll("<template shadowrootmode=\"");
try writer.writeAll(@tagName(shadow._mode));
try writer.writeByte('"');
if (shadow._delegates_focus) {
try writer.writeAll(" shadowrootdelegatesfocus=\"\"");
}
if (shadow._serializable) {
try writer.writeAll(" shadowrootserializable=\"\"");
}
if (shadow._clonable) {
try writer.writeAll(" shadowrootclonable=\"\"");
}
try writer.writeByte('>');
try children(shadow.asNode(), opts, writer, frame);
try writer.writeAll("</template>");
}
fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, frame: *Frame) !void {
const assigned = slot.assignedNodes(null, frame) catch return;
+161
View File
@@ -0,0 +1,161 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id="matches_inner_html_without_options">
{
const div = document.createElement('div');
div.innerHTML = '<span id="a">one</span><p class="x">two &amp; three</p>';
document.body.appendChild(div);
testing.expectEqual(div.innerHTML, div.getHTML());
testing.expectEqual(div.innerHTML, div.getHTML({ serializableShadowRoots: false }));
// A shadow host without qualification also matches innerHTML.
const host = document.createElement('div');
host.attachShadow({ mode: 'open' }).innerHTML = '<b>hidden</b>';
host.innerHTML = '<i>light</i>';
div.appendChild(host);
testing.expectEqual(div.innerHTML, div.getHTML());
testing.expectEqual(div.innerHTML, div.getHTML({ serializableShadowRoots: true }));
div.remove();
}
</script>
<script id="serializable_shadow_roots">
{
const wrapper = document.createElement('div');
const host = document.createElement('div');
wrapper.appendChild(host);
const shadow = host.attachShadow({ mode: 'open', serializable: true });
shadow.innerHTML = '<slot></slot>';
host.innerHTML = '<span>light</span>';
testing.expectEqual(
'<div><template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span></div>',
wrapper.getHTML({ serializableShadowRoots: true }));
// default is false
testing.expectEqual('<div><span>light</span></div>', wrapper.getHTML());
}
</script>
<script id="flag_attributes_and_order">
{
const wrapper = document.createElement('div');
const host = document.createElement('section');
wrapper.appendChild(host);
const shadow = host.attachShadow({
mode: 'closed',
serializable: true,
clonable: true,
delegatesFocus: true,
});
shadow.appendChild(document.createElement('slot'));
testing.expectEqual(
'<section><template shadowrootmode="closed" shadowrootdelegatesfocus="" shadowrootserializable="" shadowrootclonable=""><slot></slot></template></section>',
wrapper.getHTML({ serializableShadowRoots: true }));
}
</script>
<script id="own_shadow_root_serializes_first">
{
// getHTML called on the host itself: its own template precedes its light
// children.
const host = document.createElement('article');
const shadow = host.attachShadow({ mode: 'open', serializable: true });
shadow.innerHTML = '<slot></slot>';
host.innerHTML = '<span>light</span>';
testing.expectEqual(
'<template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span>',
host.getHTML({ serializableShadowRoots: true }));
testing.expectEqual(
'<template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span>',
host.getHTML({ shadowRoots: [shadow] }));
// ...but not when it doesn't qualify.
testing.expectEqual('<span>light</span>', host.getHTML());
testing.expectEqual(host.innerHTML, host.getHTML());
}
</script>
<script id="explicit_shadow_roots_list">
{
const wrapper = document.createElement('div');
const host = document.createElement('div');
wrapper.appendChild(host);
// Not serializable, closed — only reachable via the explicit list.
const shadow = host.attachShadow({ mode: 'closed' });
shadow.innerHTML = '<em>secret</em>';
testing.expectEqual('<div></div>', wrapper.getHTML({ serializableShadowRoots: true }));
const expected = '<div><template shadowrootmode="closed"><em>secret</em></template></div>';
testing.expectEqual(expected, wrapper.getHTML({ shadowRoots: [shadow] }));
testing.expectEqual(expected, wrapper.getHTML({ serializableShadowRoots: false, shadowRoots: [shadow] }));
testing.expectEqual(expected, wrapper.getHTML({ serializableShadowRoots: true, shadowRoots: [shadow] }));
}
</script>
<script id="shadow_root_get_html">
{
// ShadowRoot.getHTML serializes the shadow root's own children.
const host = document.createElement('div');
document.body.appendChild(host);
const outer = host.attachShadow({ mode: 'open' });
const inner_host = document.createElement('div');
outer.appendChild(inner_host);
const inner = inner_host.attachShadow({ mode: 'open', serializable: true });
inner.innerHTML = '<slot></slot>';
inner_host.innerHTML = '<span>light</span>';
testing.expectEqual(
'<div><template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span></div>',
outer.getHTML({ serializableShadowRoots: true }));
testing.expectEqual(outer.innerHTML, outer.getHTML());
// The flag attribute reflects the root's serializable state even when it
// qualified via the explicit list.
testing.expectEqual(
'<div><template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span></div>',
outer.getHTML({ shadowRoots: [inner] }));
host.remove();
}
</script>
<script id="nested_serializable_roots">
{
const wrapper = document.createElement('div');
const host = document.createElement('div');
wrapper.appendChild(host);
const outer = host.attachShadow({ mode: 'open', serializable: true });
const inner_host = document.createElement('p');
outer.appendChild(inner_host);
const inner = inner_host.attachShadow({ mode: 'open', serializable: true });
inner.innerHTML = '<b>deep</b>';
testing.expectEqual(
'<div><template shadowrootmode="open" shadowrootserializable=""><p><template shadowrootmode="open" shadowrootserializable=""><b>deep</b></template></p></template></div>',
wrapper.getHTML({ serializableShadowRoots: true }));
// The explicit list does not descend for non-listed nested roots.
testing.expectEqual(
'<div><template shadowrootmode="open" shadowrootserializable=""><p></p></template></div>',
wrapper.getHTML({ shadowRoots: [outer] }));
}
</script>
<script id="declarative_root_round_trip">
{
const wrapper = document.createElement('div');
document.body.appendChild(wrapper);
const html = '<div><template shadowrootmode="open" shadowrootserializable=""><slot></slot></template><span>light</span></div>';
wrapper.setHTMLUnsafe(html);
testing.expectEqual(html, wrapper.getHTML({ serializableShadowRoots: true }));
testing.expectEqual('<div><span>light</span></div>', wrapper.getHTML());
wrapper.remove();
}
</script>
+4 -1
View File
@@ -154,7 +154,10 @@ pub fn moveBefore(self: *DocumentFragment, node: js.Value, child: js.Value, fram
pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Frame) !void {
const dump = @import("../dump.zig");
return dump.children(self.asNode(), .{ .shadow = .complete }, writer, frame);
// Fragment serialization never includes nested shadow trees — a host
// inside this fragment (or shadow root) serializes only its light
// children, exactly like Element.innerHTML.
return dump.children(self.asNode(), .{ .shadow = .skip }, writer, frame);
}
pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
+21 -3
View File
@@ -20,10 +20,11 @@ const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const dump = @import("../dump.zig");
const Frame = @import("../Frame.zig");
const reflect = @import("../reflect.zig");
const Factory = @import("../Factory.zig");
const StyleManager = @import("../StyleManager.zig");
const reflect = @import("../reflect.zig");
const CSS = @import("CSS.zig");
const Node = @import("Node.zig");
@@ -518,7 +519,6 @@ pub fn insertAdjacentHTML(
}
pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
const dump = @import("../dump.zig");
return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, frame);
}
@@ -582,10 +582,13 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
}
pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, frame: *Frame) !void {
const dump = @import("../dump.zig");
return dump.children(self.asNode(), .{ .shadow = .skip }, writer, frame);
}
pub fn getHTML(self: *Element, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
return dump.getHTML(self.asNode(), opts, writer, frame);
}
pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
return parent.setHTML(html, false, frame);
@@ -2329,6 +2332,21 @@ pub const JsApi = struct {
return self.setInnerHTML(if (value.isNull()) "" else try value.toZig([]const u8), frame);
}
pub const getHTML = bridge.function(_getHTML, .{});
const GetHTMLOpts = struct {
serializableShadowRoots: bool = false,
shadowRoots: []const *ShadowRoot = &.{},
};
fn _getHTML(self: *Element, opts_: ?GetHTMLOpts, frame: *Frame) ![]const u8 {
const opts = opts_ orelse GetHTMLOpts{};
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getHTML(.{
.shadow_roots = opts.shadowRoots,
.serializable_shadow_roots = opts.serializableShadowRoots,
}, &buf.writer, frame);
return buf.written();
}
pub const prefix = bridge.accessor(Element._prefix, null, .{});
pub const setAttribute = bridge.function(_setAttribute, .{ .ce_reactions = true });
+21 -1
View File
@@ -19,10 +19,12 @@
const std = @import("std");
const js = @import("../js/js.zig");
const dump = @import("../dump.zig");
const Frame = @import("../Frame.zig");
const Node = @import("Node.zig");
const DocumentFragment = @import("DocumentFragment.zig");
const Element = @import("Element.zig");
const DocumentFragment = @import("DocumentFragment.zig");
const ShadowRoot = @This();
@@ -112,6 +114,10 @@ pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void {
return self.asDocumentFragment().setHTMLUnsafe(html, frame);
}
pub fn getHTML(self: *ShadowRoot, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void {
return dump.getHTML(self.asNode(), opts, writer, frame);
}
pub fn getOnSlotChange(self: *ShadowRoot, frame: *Frame) ?js.Function.Global {
return frame._event_target_attr_listeners.get(.{ .target = self.asEventTarget(), .handler = .onslotchange });
}
@@ -214,6 +220,20 @@ pub const JsApi = struct {
}
pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{});
pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .ce_reactions = true });
pub const getHTML = bridge.function(_getHTML, .{});
const GetHTMLOpts = struct {
serializableShadowRoots: bool = false,
shadowRoots: []const *ShadowRoot = &.{},
};
fn _getHTML(self: *ShadowRoot, opts_: ?GetHTMLOpts, frame: *Frame) ![]const u8 {
const opts = opts_ orelse GetHTMLOpts{};
var buf = std.Io.Writer.Allocating.init(frame.local_arena);
try self.getHTML(.{
.shadow_roots = opts.shadowRoots,
.serializable_shadow_roots = opts.serializableShadowRoots,
}, &buf.writer, frame);
return buf.written();
}
pub const onslotchange = bridge.accessor(ShadowRoot.getOnSlotChange, ShadowRoot.setOnSlotChange, .{});
};