From f5da89b50bf4f7436d767901dd533f1a7c9bf390 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 7 Aug 2025 16:47:31 +0800 Subject: [PATCH] lit compatibility Aims to improve compatibility for the lit framework (e.g. what Reddit is using). 1 - Adds support for adoptedStyleSheets to the Document and ShadowRoot 2 - Adds mock support for replace and replaceSync to the CSSStyleSheet 3 - Optionally include shadowroot in dump 4 - Special-case setting innerHTML on a TemplateElement --- src/browser/State.zig | 2 ++ src/browser/cssom/CSSStyleSheet.zig | 30 ++++++++++++++++++++++++- src/browser/dom/document.zig | 23 +++++++++++++++++++ src/browser/dom/element.zig | 34 ++++++++++++++++++++++++----- src/browser/dom/shadow_root.zig | 25 +++++++++++++++++++++ src/browser/dump.zig | 12 ++++++++++ src/browser/html/elements.zig | 6 +++++ src/browser/page.zig | 5 ++++- src/main.zig | 3 ++- src/runtime/js.zig | 8 +++++++ 10 files changed, 140 insertions(+), 8 deletions(-) diff --git a/src/browser/State.zig b/src/browser/State.zig index d53f80be..3e046d6c 100644 --- a/src/browser/State.zig +++ b/src/browser/State.zig @@ -31,6 +31,7 @@ const parser = @import("netsurf.zig"); const DataSet = @import("html/DataSet.zig"); const ShadowRoot = @import("dom/shadow_root.zig").ShadowRoot; const StyleSheet = @import("cssom/StyleSheet.zig"); +const CSSStyleSheet = @import("cssom/CSSStyleSheet.zig"); const CSSStyleDeclaration = @import("cssom/CSSStyleDeclaration.zig"); // for HTMLScript (but probably needs to be added to more) @@ -53,6 +54,7 @@ style_sheet: ?*StyleSheet = null, // for dom/document active_element: ?*parser.Element = null, +adopted_style_sheets: ?Env.JsObject = null, // for HTMLSelectElement // By default, if no option is explicitly selected, the first option should diff --git a/src/browser/cssom/CSSStyleSheet.zig b/src/browser/cssom/CSSStyleSheet.zig index bf12c02e..ba6dc381 100644 --- a/src/browser/cssom/CSSStyleSheet.zig +++ b/src/browser/cssom/CSSStyleSheet.zig @@ -18,6 +18,7 @@ const std = @import("std"); +const Env = @import("../env.zig").Env; const Page = @import("../page.zig").Page; const StyleSheet = @import("StyleSheet.zig"); const CSSRuleList = @import("CSSRuleList.zig"); @@ -39,7 +40,7 @@ const CSSStyleSheetOpts = struct { pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet { const opts = _opts orelse CSSStyleSheetOpts{}; return .{ - .proto = StyleSheet{ .disabled = opts.disabled }, + .proto = .{ .disabled = opts.disabled }, .css_rules = .constructor(), .owner_rule = null, }; @@ -72,6 +73,24 @@ pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void { _ = self.css_rules.list.orderedRemove(index); } +pub fn _replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !Env.Promise { + _ = self; + _ = text; + // TODO: clear self.css_rules + // parse text and re-populate self.css_rules + + const resolver = page.main_context.createPromiseResolver(); + try resolver.resolve({}); + return resolver.promise(); +} + +pub fn _replaceSync(self: *CSSStyleSheet, text: []const u8) !void { + _ = self; + _ = text; + // TODO: clear self.css_rules + // parse text and re-populate self.css_rules +} + const testing = @import("../../testing.zig"); test "Browser.CSS.StyleSheet" { var runner = try testing.jsRunner(testing.tracking_allocator, .{}); @@ -85,5 +104,14 @@ test "Browser.CSS.StyleSheet" { .{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" }, .{ "index1", "0" }, .{ "css.cssRules.length", "1" }, + + .{ + \\ let replaced = false; + \\ css.replace('body{}').then(() => replaced = true); + , + null, + }, + // microtasks are run between each statement + .{ "replaced", "true" }, }, .{}); } diff --git a/src/browser/dom/document.zig b/src/browser/dom/document.zig index 7a6b5b6d..a1819e9e 100644 --- a/src/browser/dom/document.zig +++ b/src/browser/dom/document.zig @@ -293,6 +293,22 @@ pub const Document = struct { pub fn get_styleSheets(_: *parser.Document) []CSSStyleSheet { return &.{}; } + + pub fn get_adoptedStyleSheets(self: *parser.Document, page: *Page) !Env.JsObject { + const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self))); + if (state.adopted_style_sheets) |obj| { + return obj; + } + + const obj = try page.main_context.newArray(0).persist(); + state.adopted_style_sheets = obj; + return obj; + } + + pub fn set_adoptedStyleSheets(self: *parser.Document, sheets: Env.JsObject, page: *Page) !void { + const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self))); + state.adopted_style_sheets = try sheets.persist(); + } }; const testing = @import("../../testing.zig"); @@ -484,6 +500,13 @@ test "Browser.DOM.Document" { .{ "v.nodeName", "DIV" }, }, .{}); + try runner.testCases(&.{ + .{ "const acss = document.adoptedStyleSheets", null }, + .{ "acss.length", "0" }, + .{ "acss.push(new CSSStyleSheet())", null }, + .{ "document.adoptedStyleSheets.length", "1" }, + }, .{}); + const Case = testing.JsRunner.Case; const tags = comptime parser.Tag.all(); var createElements: [(tags.len) * 2]Case = undefined; diff --git a/src/browser/dom/element.zig b/src/browser/dom/element.zig index 35153b73..3746abcc 100644 --- a/src/browser/dom/element.zig +++ b/src/browser/dom/element.zig @@ -137,18 +137,18 @@ pub const Element = struct { } pub fn get_innerHTML(self: *parser.Element, page: *Page) ![]const u8 { - var buf = std.ArrayList(u8).init(page.arena); + var buf = std.ArrayList(u8).init(page.call_arena); try dump.writeChildren(parser.elementToNode(self), .{}, buf.writer()); return buf.items; } pub fn get_outerHTML(self: *parser.Element, page: *Page) ![]const u8 { - var buf = std.ArrayList(u8).init(page.arena); + var buf = std.ArrayList(u8).init(page.call_arena); try dump.writeNode(parser.elementToNode(self), .{}, buf.writer()); return buf.items; } - pub fn set_innerHTML(self: *parser.Element, str: []const u8) !void { + pub fn set_innerHTML(self: *parser.Element, str: []const u8, page: *Page) !void { const node = parser.elementToNode(self); const doc = try parser.nodeOwnerDocument(node) orelse return parser.DOMError.WrongDocument; // parse the fragment @@ -157,6 +157,8 @@ pub const Element = struct { // remove existing children try Node.removeChildren(node); + const fragment_node = parser.documentFragmentToNode(fragment); + // I'm not sure what the exact behavior is supposed to be. Initially, // we were only copying the body of the document fragment. But it seems // like head elements should be copied too. Specifically, some sites @@ -166,9 +168,32 @@ pub const Element = struct { // or an actual document. In a blank page, something like: // x.innerHTML = ''; // does _not_ create an empty script, but in a real page, it does. Weird. - const fragment_node = parser.documentFragmentToNode(fragment); const html = try parser.nodeFirstChild(fragment_node) orelse return; const head = try parser.nodeFirstChild(html) orelse return; + const body = try parser.nodeNextSibling(head) orelse return; + + if (try parser.elementTag(self) == .template) { + // HTMLElementTemplate is special. We don't append these as children + // of the template, but instead set its content as the body of the + // fragment. Simpler to do this by copying the body children into + // a new fragment + const clean = try parser.documentCreateDocumentFragment(doc); + const children = try parser.nodeGetChildNodes(body); + const ln = try parser.nodeListLength(children); + for (0..ln) |_| { + // always index 0, because nodeAppendChild moves the node out of + // the nodeList and into the new tree + const child = try parser.nodeListItem(children, 0) orelse continue; + _ = try parser.nodeAppendChild(@alignCast(@ptrCast(clean)), child); + } + + const state = try page.getOrCreateNodeState(node); + state.template_content = clean; + return; + } + + // For any node other than a template, we copy the head and body elements + // as child nodes of the element { // First, copy some of the head element const children = try parser.nodeGetChildNodes(head); @@ -182,7 +207,6 @@ pub const Element = struct { } { - const body = try parser.nodeNextSibling(head) orelse return; const children = try parser.nodeGetChildNodes(body); const ln = try parser.nodeListLength(children); for (0..ln) |_| { diff --git a/src/browser/dom/shadow_root.zig b/src/browser/dom/shadow_root.zig index 7d806855..7d8977df 100644 --- a/src/browser/dom/shadow_root.zig +++ b/src/browser/dom/shadow_root.zig @@ -18,6 +18,9 @@ const std = @import("std"); const parser = @import("../netsurf.zig"); + +const Env = @import("../env.zig").Env; +const Page = @import("../page.zig").Page; const Element = @import("element.zig").Element; const ElementUnion = @import("element.zig").Union; @@ -29,6 +32,7 @@ pub const ShadowRoot = struct { mode: Mode, host: *parser.Element, proto: *parser.DocumentFragment, + adopted_style_sheets: ?Env.JsObject = null, pub const Mode = enum { open, @@ -38,6 +42,20 @@ pub const ShadowRoot = struct { pub fn get_host(self: *const ShadowRoot) !ElementUnion { return Element.toInterface(self.host); } + + pub fn get_adoptedStyleSheets(self: *ShadowRoot, page: *Page) !Env.JsObject { + if (self.adopted_style_sheets) |obj| { + return obj; + } + + const obj = try page.main_context.newArray(0).persist(); + self.adopted_style_sheets = obj; + return obj; + } + + pub fn set_adoptedStyleSheets(self: *ShadowRoot, sheets: Env.JsObject) !void { + self.adopted_style_sheets = try sheets.persist(); + } }; const testing = @import("../../testing.zig"); @@ -80,4 +98,11 @@ test "Browser.DOM.ShadowRoot" { .{ "sr2.append(n1)", null}, .{ "sr2.getElementById('conflict') == n1", "true" }, }, .{}); + + try runner.testCases(&.{ + .{ "const acss = sr2.adoptedStyleSheets", null }, + .{ "acss.length", "0" }, + .{ "acss.push(new CSSStyleSheet())", null }, + .{ "sr2.adoptedStyleSheets.length", "1" }, + }, .{}); } diff --git a/src/browser/dump.zig b/src/browser/dump.zig index 68dc6086..da890b98 100644 --- a/src/browser/dump.zig +++ b/src/browser/dump.zig @@ -19,9 +19,13 @@ const std = @import("std"); const parser = @import("netsurf.zig"); +const Page = @import("page.zig").Page; const Walker = @import("dom/walker.zig").WalkerChildren; pub const Opts = struct { + // set to include element shadowroots in the dump + page: ?*const Page = null, + exclude_scripts: bool = false, }; @@ -88,6 +92,14 @@ pub fn writeNode(node: *parser.Node, opts: Opts, writer: anytype) anyerror!void try writer.writeAll(">"); + if (opts.page) |page| { + if (page.getNodeState(node)) |state| { + if (state.shadow_root) |sr| { + try writeChildren(@alignCast(@ptrCast(sr.proto)), opts, writer); + } + } + } + // void elements can't have any content. if (try isVoid(parser.nodeToElement(node))) return; diff --git a/src/browser/html/elements.zig b/src/browser/html/elements.zig index 5be01a1e..af0bd9ef 100644 --- a/src/browser/html/elements.zig +++ b/src/browser/html/elements.zig @@ -1454,6 +1454,12 @@ test "Browser.HTML.HTMLTemplateElement" { .{ "document.getElementById('abc')", "null" }, .{ "document.getElementById('c').appendChild(t.content.cloneNode(true))", null }, .{ "document.getElementById('abc').id", "abc" }, + .{ "t.innerHTML = 'over

9000!

';", null }, + .{ "t.content.childNodes.length", "2" }, + .{ "t.content.childNodes[0].tagName", "SPAN" }, + .{ "t.content.childNodes[0].innerHTML", "over" }, + .{ "t.content.childNodes[1].tagName", "P" }, + .{ "t.content.childNodes[1].innerHTML", "9000!" }, }, .{}); } diff --git a/src/browser/page.zig b/src/browser/page.zig index ba57167b..de78f63a 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -142,8 +142,10 @@ pub const Page = struct { } pub const DumpOpts = struct { - exclude_scripts: bool = false, + // set to include element shadowroots in the dump + page: ?*const Page = null, with_base: bool = false, + exclude_scripts: bool = false, }; // dump writes the page content into the given file. @@ -162,6 +164,7 @@ pub const Page = struct { } try Dump.writeHTML(doc, .{ + .page = opts.page, .exclude_scripts = opts.exclude_scripts, }, out); } diff --git a/src/main.zig b/src/main.zig index 4ca6db09..5e49ecb5 100644 --- a/src/main.zig +++ b/src/main.zig @@ -135,8 +135,9 @@ fn run(alloc: Allocator) !void { // dump if (opts.dump) { try page.dump(.{ - .exclude_scripts = opts.noscript, + .page = page, .with_base = opts.withbase, + .exclude_scripts = opts.noscript, }, std.io.getStdOut()); } }, diff --git a/src/runtime/js.zig b/src/runtime/js.zig index ca93ece6..8b724a25 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -799,6 +799,14 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { return promise; } + pub fn newArray(self: *JsContext, len: u32) JsObject { + const arr = v8.Array.init(self.isolate, len); + return .{ + .js_context = self, + .js_obj = arr.castTo(v8.Object), + }; + } + // Wrap a v8.Exception fn createException(self: *const JsContext, e: v8.Value) Exception { return .{