From 0740f7d11333d734793927efef7fcca9ec850467 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:36:09 +0200 Subject: [PATCH 1/6] js: required-but-nullable arguments; fix EventTarget/AbortSignal IDL shape Fixes 6 failing tests in WPT /dom/idlharness.any.worker.html (211/219 -> 217/219) and the same assertions in the other idlharness variants: - addEventListener/removeEventListener had .length 1 instead of 2 and accepted being called with a single argument. Per Web IDL their callback parameter is required but nullable ("EventListener?"), which a plain Zig optional can't express (the bridge treats optionals as omittable and stops counting length at the first one). The new js.Nullable(T) wrapper marks a required argument that accepts null or undefined: omitting it throws a TypeError and it counts towards the function's length. - new AbortSignal() didn't throw: the spec defines no constructor, so the JsApi constructor is removed (the interface object now uses the illegal-constructor callback; AbortSignal.init stays for internal use). - AbortSignal.any() with no argument didn't throw: the trailing slice parameter was treated as variadic and defaulted to empty. The parameter is now a required js.Value converted to the signal sequence explicitly. The 2 remaining failures need strict-mode receiver validation on the flattened worker global and the DOMStringList interface. Coverage: /dom/idlharness.any.worker.html 211/219 -> 217/219. No regressions across /dom/events and /dom/abort. Co-Authored-By: Claude Fable 5 --- src/browser/js/Local.zig | 8 ++++++++ src/browser/js/js.zig | 12 ++++++++++++ src/browser/webapi/AbortSignal.zig | 11 +++++++++-- src/browser/webapi/EventTarget.zig | 8 ++++---- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index b24340c9b..192537f32 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -742,6 +742,14 @@ pub fn jsValueToZig(self: *const Local, comptime T: type, js_val: js.Value) !T { // Extracted so that it can be used in both jsValueToZig and in // probeJsValueToZig. Avoids having to duplicate this logic when probing. fn jsValueToStruct(self: *const Local, comptime T: type, js_val: js.Value) !?T { + // js.Nullable(T): a required argument that accepts null. + if (@hasDecl(T, "js_nullable")) { + if (js_val.isNullOrUndefined()) { + return T{ .value = null }; + } + return T{ .value = try self.jsValueToZig(T.js_nullable, js_val) }; + } + return switch (T) { js.Function, js.Function.Global => { if (!js_val.isFunction()) { diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 675f6955b..a8bec4d91 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -284,6 +284,18 @@ pub const NullableString = struct { value: []const u8, }; +// A required argument that accepts null (Web IDL "T?"): unlike a Zig optional +// parameter, omitting the argument is a TypeError, while passing null or +// undefined yields .{ .value = null }. It also counts towards the JS-visible +// function length, which a plain optional would not. +pub fn Nullable(comptime T: type) type { + return struct { + value: ?T, + + pub const js_nullable = T; + }; +} + pub const Exception = struct { local: *const Local, handle: *const v8.Value, diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index b9e34faec..9b679cbb2 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -167,7 +167,12 @@ pub fn createAborted(reason_: ?js.Value, exec: *const Execution) !*AbortSignal { return signal; } -pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortSignal { +pub fn createAny(signals_value: js.Value, exec: *const Execution) !*AbortSignal { + // A js.Value parameter (not a slice) so the argument is required — the + // bridge would treat a trailing slice as variadic and default it to + // empty, but AbortSignal.any() must throw when called with no argument. + const signals = try signals_value.toZig([]const *AbortSignal); + const result = try init(exec); for (signals) |source| { if (source._aborted) { @@ -279,7 +284,9 @@ pub const JsApi = struct { pub const Prototype = EventTarget; - pub const constructor = bridge.constructor(AbortSignal.init, .{}); + // Per spec AbortSignal has no constructor; instances are created via the + // static methods and AbortController. (AbortSignal.init stays for + // internal use.) pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{}); pub const reason = bridge.accessor(AbortSignal.getReason, null, .{}); pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{}); diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index f0c25e1c3..b083969be 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -138,7 +138,7 @@ pub const EventListenerCallback = union(enum) { function: js.Function, object: js.Object, }; -pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, exec: *js.Execution) !void { +pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: js.Nullable(EventListenerCallback), opts_: ?AddEventListenerOptions, exec: *js.Execution) !void { // Convert the options before the null-callback early return: per spec, // the dictionary conversion throws even when the callback is null. const options = blk: { @@ -160,7 +160,7 @@ pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventLi }; }; - const callback = callback_ orelse return; + const callback = callback_.value orelse return; const em_callback: EventManager.Callback = switch (callback) { .object => |obj| .{ .object = obj }, @@ -180,8 +180,8 @@ const RemoveEventListenerOptions = union(enum) { capture: bool = false, }; }; -pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, exec: *js.Execution) !void { - const callback = callback_ orelse return; +pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: js.Nullable(EventListenerCallback), opts_: ?RemoveEventListenerOptions, exec: *js.Execution) !void { + const callback = callback_.value orelse return; // For object callbacks, check if handleEvent exists if (callback == .object) { From 0a7e24fa2f0b2d6eb109a4a6f3ecfe499675a16d Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:42:39 +0200 Subject: [PATCH 2/6] webapi: DOMTokenList-reflected attributes on area, output, iframe, link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 4 failing tests in WPT /dom/lists/DOMTokenList-coverage-for-attributes.html (135/140 -> 139/140): area.relList, output.htmlFor, iframe.sandbox and link.sizes must be DOMTokenList attributes reflecting their content attributes. Element gains a generic getTokenList lookup (keyed by element and attribute) alongside the existing class/rel dedicated ones, and the four elements expose the accessors following the Anchor/Link relList pattern (undefined outside the HTML namespace). The remaining failure needs an SVGAElement interface (relList on SVG elements), which doesn't exist yet — SVG elements are all generic. Coverage: /dom/lists/DOMTokenList-coverage-for-attributes.html 135/140 -> 139/140. Co-Authored-By: Claude Fable 5 --- src/browser/Frame.zig | 1 + src/browser/webapi/Element.zig | 17 +++++++++++++++++ src/browser/webapi/element/html/Area.zig | 7 +++---- src/browser/webapi/element/html/IFrame.zig | 9 +++++++++ src/browser/webapi/element/html/Link.zig | 9 +++++++++ src/browser/webapi/element/html/Output.zig | 9 +++++++++ 6 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index e55af9316..3631ffb8f 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -134,6 +134,7 @@ _element_computed_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, +_element_token_lists: Element.TokenListLookup = .empty, _element_shadow_roots: Element.ShadowRootLookup = .empty, _node_owner_documents: Node.OwnerDocumentLookup = .empty, _element_scroll_positions: Element.ScrollPositionLookup = .empty, diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 42150597b..9edf06296 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -885,6 +885,23 @@ pub fn getRelList(self: *Element, frame: *Frame) !*collections.DOMTokenList { return gop.value_ptr.*; } +// The other DOMTokenList-reflected attributes (class and rel have dedicated +// lookups above). +pub const TokenListAttribute = enum { sizes, sandbox, @"for" }; +pub const TokenListKey = struct { element: *Element, attribute: TokenListAttribute }; +pub const TokenListLookup = std.AutoHashMapUnmanaged(TokenListKey, *collections.DOMTokenList); + +pub fn getTokenList(self: *Element, comptime attribute: TokenListAttribute, frame: *Frame) !*collections.DOMTokenList { + const gop = try frame._element_token_lists.getOrPut(frame.arena, .{ .element = self, .attribute = attribute }); + if (!gop.found_existing) { + gop.value_ptr.* = try frame._factory.create(collections.DOMTokenList{ + ._element = self, + ._attribute_name = comptime .wrap(@tagName(attribute)), + }); + } + return gop.value_ptr.*; +} + pub fn getDataset(self: *Element, frame: *Frame) !*DOMStringMap { const gop = try frame._element_datasets.getOrPut(frame.arena, self); if (!gop.found_existing) { diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index 0c9c821bc..edf6a96ef 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -290,12 +290,11 @@ pub const JsApi = struct { pub const referrerPolicy = bridge.accessor(Area.getReferrerPolicy, Area.setReferrerPolicy, .{ .ce_reactions = true }); pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); pub const toString = bridge.function(Area.getHref, .{}); - + pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); fn _getRelList(self: *Area, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { const element = self.asElement(); - // relList is only valid for HTML and SVG elements - const namespace = element._namespace; - if (namespace != .html and namespace != .svg) { + // relList is only valid for HTML elements + if (element._namespace != .html) { return null; } return element.getRelList(frame); diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index a5163c8e4..5e2153b76 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -93,6 +93,15 @@ pub const JsApi = struct { pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true }); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); + pub const sandbox = bridge.accessor(_getSandbox, null, .{ .null_as_undefined = true }); + + fn _getSandbox(self: *IFrame, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.sandbox, frame); + } }; pub const Build = struct { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 7eb2190f5..06eee926d 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -256,6 +256,15 @@ pub const JsApi = struct { pub const rev = bridge.accessor(Link.getRev, Link.setRev, .{ .ce_reactions = true }); pub const target = bridge.accessor(Link.getTarget, Link.setTarget, .{ .ce_reactions = true }); pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); + pub const sizes = bridge.accessor(_getSizes, null, .{ .null_as_undefined = true }); + + fn _getSizes(self: *Link, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.sizes, frame); + } fn _getRelList(self: *Link, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { const element = self.asElement(); diff --git a/src/browser/webapi/element/html/Output.zig b/src/browser/webapi/element/html/Output.zig index 207e03a4b..96240077e 100644 --- a/src/browser/webapi/element/html/Output.zig +++ b/src/browser/webapi/element/html/Output.zig @@ -29,4 +29,13 @@ pub const JsApi = struct { }; pub const labels = bridge.accessor(Output.getLabels, null, .{}); + pub const htmlFor = bridge.accessor(_getHtmlFor, null, .{ .null_as_undefined = true }); + + fn _getHtmlFor(self: *Output, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.@"for", frame); + } }; From 4c306d50b7c63fab1c72ac00429ac935491309c8 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 21:50:31 +0200 Subject: [PATCH 3/6] webapi: DOMImplementation owns its document; validate doctype names Fixes 81 failing tests in WPT /dom/nodes/DOMImplementation-createDocumentType.html (1/82 -> 82/82): - DOMImplementation was an empty singleton-style object with no link to its document, so a doctype created through another document's implementation (e.g. createHTMLDocument().implementation) reported the frame's main document as its ownerDocument. The implementation object now stores its associated document and registers created doctypes in the frame's node-owner-document map, following the createDocumentFragment pattern. - createDocumentType now validates the qualified name against the doctype name production (no ASCII whitespace or '>'), throwing InvalidCharacterError. Coverage: /dom/nodes/DOMImplementation-createDocumentType.html 1/82 -> 82/82. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/DOMImplementation.zig | 23 +++++++++++++++++++---- src/browser/webapi/Document.zig | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index 21e30804c..2ee3df218 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -23,10 +23,26 @@ const Document = @import("Document.zig"); const DocumentType = @import("DocumentType.zig"); const DOMImplementation = @This(); -_pad: bool = false, -pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType { - return DocumentType.init(qualified_name, public_id, system_id, frame); +// The document this implementation object belongs to: nodes created through +// it are owned by that document, not necessarily the frame's main document. +_document: *Document, + +pub fn createDocumentType(self: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, frame: *Frame) !*DocumentType { + // Per spec, qualifiedName must match the doctype name production: any + // characters except ASCII whitespace or '>'. + for (qualified_name) |c| { + switch (c) { + '\t', '\n', 0x0C, '\r', ' ', '>' => return error.InvalidCharacterError, + else => {}, + } + } + + const doctype = try DocumentType.init(qualified_name, public_id, system_id, frame); + if (self._document != frame.document) { + try frame.setNodeOwnerDocument(doctype.asNode(), self._document); + } + return doctype; } pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString, frame: *Frame) !*Document { @@ -98,7 +114,6 @@ pub const JsApi = struct { pub const name = "DOMImplementation"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const empty_with_no_proto = true; }; pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{}); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 10548f3b8..3cafd8e6c 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -419,7 +419,7 @@ pub fn querySelectorAll(self: *Document, input: String, frame: *Frame) !*Selecto pub fn getImplementation(self: *Document, frame: *Frame) !*DOMImplementation { if (self._implementation) |impl| return impl; - const impl = try frame._factory.create(DOMImplementation{}); + const impl = try frame._factory.create(DOMImplementation{ ._document = self }); self._implementation = impl; return impl; } From aba796eb363687ffa944b89fc0d77c3bd85f8d8a Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Fri, 10 Jul 2026 22:12:54 +0200 Subject: [PATCH 4/6] webapi: implement validate-and-extract; real namespace URIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes 158 failing tests in WPT /dom/nodes/DOMImplementation-createDocument.html (276/434 -> 434/434) and improves Document-createElementNS.html (82/596 -> 197/596), Document-createElement.html (29/147 -> 34/147), Document-contentType/createDocument.html (0/1 -> 1/1) and name-validation.html (0/5 -> 2/5). The remaining failures in those files need XML/XHTML document loading, which Lightpanda doesn't have. - Document now implements the DOM spec's §1.4 name validation productions (valid element/attribute local name, valid namespace prefix) and the "validate and extract" algorithm, replacing the older ad-hoc element name check: prefixes are validated, and the prefix/namespace consistency rules throw NamespaceError (prefix without namespace, xml/xmlns mismatches). - createElementNS and DOMImplementation.createDocument route through it. createDocument also now: treats its two first arguments as required (namespace via the js.Nullable wrapper, qualifiedName as a raw js.Value so [LegacyNullToEmptyString] null maps to "" while undefined stringifies), sets the spec's namespace-dependent contentType, and registers custom namespace URIs for the root element. - element.namespaceURI now returns the actual URI for namespaces outside the built-in set (it returned a lightpanda.io placeholder), and Element.clone copies the registration. The unit tests asserting the placeholder are updated to the correct values. Coverage: DOMImplementation-createDocument.html 276/434 -> 434/434 and the improvements above. No regressions across /dom/nodes; 1002/1002 unit tests pass. Co-Authored-By: Claude Fable 5 --- .../tests/document/create_element_ns.html | 5 +- src/browser/tests/domimplementation.html | 3 +- src/browser/webapi/DOMImplementation.zig | 45 ++++++- src/browser/webapi/Document.zig | 123 ++++++++++++++++-- src/browser/webapi/Element.zig | 12 +- src/browser/webapi/element/html/Area.zig | 18 +-- 6 files changed, 172 insertions(+), 34 deletions(-) diff --git a/src/browser/tests/document/create_element_ns.html b/src/browser/tests/document/create_element_ns.html index c99a51674..095025dce 100644 --- a/src/browser/tests/document/create_element_ns.html +++ b/src/browser/tests/document/create_element_ns.html @@ -32,8 +32,7 @@ const unknownNsElement = document.createElementNS('http://example.com/unknown', 'custom'); testing.expectEqual('custom', unknownNsElement.tagName); - // Should be http://example.com/unknown - testing.expectEqual('http://lightpanda.io/unsupported/namespace', unknownNsElement.namespaceURI); + testing.expectEqual('http://example.com/unknown', unknownNsElement.namespaceURI); const regularDiv = document.createElement('div'); testing.expectEqual('DIV', regularDiv.tagName); @@ -45,5 +44,5 @@ testing.expectEqual('te:ST', custom.tagName); testing.expectEqual('te', custom.prefix); testing.expectEqual('ST', custom.localName); - testing.expectEqual('http://lightpanda.io/unsupported/namespace', custom.namespaceURI); // Should be test + testing.expectEqual('test', custom.namespaceURI); diff --git a/src/browser/tests/domimplementation.html b/src/browser/tests/domimplementation.html index 76b1a50fd..295ea8a61 100644 --- a/src/browser/tests/domimplementation.html +++ b/src/browser/tests/domimplementation.html @@ -221,8 +221,7 @@ const root = doc.documentElement; testing.expectEqual('prefix:localName', root.tagName); - // TODO: Custom namespaces are being replaced with an empty value - testing.expectEqual('http://lightpanda.io/unsupported/namespace', root.namespaceURI); + testing.expectEqual('http://example.com', root.namespaceURI); } diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index 2ee3df218..1e0a44b69 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -16,6 +16,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); const js = @import("../js/js.zig"); const Frame = @import("../Frame.zig"); const Node = @import("Node.zig"); @@ -79,10 +80,34 @@ pub fn createHTMLDocument(_: *const DOMImplementation, title: ?js.NullableString return document; } -pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qualified_name: ?[]const u8, doctype: ?*DocumentType, frame: *Frame) !*Document { +pub fn createDocument(_: *const DOMImplementation, namespace_nullable: js.Nullable([]const u8), qualified_name_: js.Value, doctype: ?*DocumentType, frame: *Frame) !*Document { + // Both namespace (nullable) and qualifiedName are required arguments. + const namespace_ = namespace_nullable.value; + + // Per Web IDL, qualifiedName is [LegacyNullToEmptyString]: null becomes + // the empty string, while undefined stringifies to "undefined". The raw + // js.Value keeps that distinction. + const qname: []const u8 = blk: { + if (qualified_name_.isNull()) { + break :blk ""; + } + break :blk try qualified_name_.toStringSlice(); + }; + + if (qname.len > 0) { + _ = try Document.validateAndExtract(namespace_, qname, .element); + } + // Create XML Document const document = (try frame._factory.document(Node.Document.XMLDocument{ ._proto = undefined })).asDocument(); document._url = "about:blank"; + // Per spec the content type depends on the requested namespace. + document._content_type = blk: { + const ns = namespace_ orelse break :blk "application/xml"; + if (std.mem.eql(u8, ns, "http://www.w3.org/1999/xhtml")) break :blk "application/xhtml+xml"; + if (std.mem.eql(u8, ns, "http://www.w3.org/2000/svg")) break :blk "image/svg+xml"; + break :blk "application/xml"; + }; // Append doctype if provided if (doctype) |dt| { @@ -90,12 +115,20 @@ pub fn createDocument(_: *const DOMImplementation, namespace_: ?[]const u8, qual } // Create and append root element if qualified_name provided - if (qualified_name) |qname| { - if (qname.len > 0) { - const namespace = Node.Element.Namespace.parse(namespace_); - const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null); - _ = try document.asNode().appendChild(root, frame); + if (qname.len > 0) { + const namespace = Node.Element.Namespace.parse(namespace_); + const root = try Frame.node_factory.createElementNS(frame, namespace, qname, null); + + // Store the original URI for unknown namespaces so namespaceURI and + // lookupNamespaceURI can return it (mirrors Document.createElementNS). + if (namespace == .unknown) { + if (namespace_) |uri| { + const duped = try frame.dupeString(uri); + try frame._element_namespace_uris.put(frame.arena, root.as(Node.Element), duped); + } } + + _ = try document.asNode().appendChild(root, frame); } return document; diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 3cafd8e6c..c0b270ff8 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -54,6 +54,8 @@ _type: Type, _proto: *Node, _frame: ?*Frame = 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, _ready_state: ReadyState = .loading, _current_script: ?*Element.Html.Script = null, _elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty, @@ -158,6 +160,9 @@ pub fn setLocation(self: *Document, url: [:0]const u8) !void { } pub fn getContentType(self: *const Document) []const u8 { + if (self._content_type) |content_type| { + return content_type; + } return switch (self._type) { .html => "text/html", .xml => "application/xml", @@ -300,7 +305,7 @@ pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElement } pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, frame: *Frame) !*Element { - try validateElementName(name); + _ = try validateAndExtract(namespace, name, .element); const ns = Element.Namespace.parse(namespace); // Per spec, createElementNS does NOT lowercase (unlike createElement). const node = try Frame.node_factory.createElementNS(frame, ns, name, null); @@ -1239,26 +1244,118 @@ fn validateDocumentNodes(self: *Document, nodes: []const Node.NodeOrText, compti } } -fn validateElementName(name: []const u8) !void { +// DOM §1.4 "Name validation" productions. + +pub fn isValidElementLocalName(name: []const u8) bool { if (name.len == 0) { - return error.InvalidCharacterError; + return false; } - - const first = name[0]; - // Element names cannot start with: digits, period, hyphen - if ((first >= '0' and first <= '9') or first == '.' or first == '-') { - return error.InvalidCharacterError; + if (std.ascii.isAlphabetic(name[0])) { + // Names the HTML parser can construct: anything except ASCII + // whitespace, NUL, '/' or '>'. + for (name[1..]) |c| { + switch (c) { + '\t', '\n', 0x0C, '\r', ' ', 0, '/', '>' => return false, + else => {}, + } + } + return true; + } + // Otherwise the first code point must be ':', '_' or beyond ASCII, and + // the rest restricted to alphanumerics, '-', '.', ':', '_' or non-ASCII. + if (name[0] != ':' and name[0] != '_' and name[0] < 0x80) { + return false; } - for (name[1..]) |c| { - const is_valid = std.ascii.isAlphanumeric(c) or - c == '_' or c == '-' or c == '.' or c == ':' or - c >= 128; // Allow non-ASCII UTF-8 + const valid = std.ascii.isAlphanumeric(c) or + c == '-' or c == '.' or c == ':' or c == '_' or c >= 0x80; + if (!valid) { + return false; + } + } + return true; +} - if (!is_valid) { +pub fn isValidNamespacePrefix(prefix: []const u8) bool { + if (prefix.len == 0) { + return false; + } + for (prefix) |c| { + switch (c) { + '\t', '\n', 0x0C, '\r', ' ', 0, '/', '>' => return false, + else => {}, + } + } + return true; +} + +pub fn isValidAttributeLocalName(name: []const u8) bool { + if (name.len == 0) { + return false; + } + for (name) |c| { + switch (c) { + '\t', '\n', 0x0C, '\r', ' ', 0, '/', '=', '>' => return false, + else => {}, + } + } + return true; +} + +fn validateElementName(name: []const u8) !void { + if (!isValidElementLocalName(name)) { + return error.InvalidCharacterError; + } +} + +pub const ValidatedName = struct { + prefix: ?[]const u8, + local_name: []const u8, + namespace: ?[]const u8, +}; + +// The DOM spec's "validate and extract a namespace and qualifiedName". +pub fn validateAndExtract(namespace_: ?[]const u8, qualified_name: []const u8, comptime context: enum { element, attribute }) !ValidatedName { + var namespace: ?[]const u8 = namespace_; + if (namespace) |ns| { + if (ns.len == 0) { + namespace = null; + } + } + + var prefix: ?[]const u8 = null; + var local_name = qualified_name; + if (std.mem.indexOfScalar(u8, qualified_name, ':')) |colon| { + prefix = qualified_name[0..colon]; + local_name = qualified_name[colon + 1 ..]; + if (!isValidNamespacePrefix(prefix.?)) { return error.InvalidCharacterError; } } + + const local_valid = switch (context) { + .element => isValidElementLocalName(local_name), + .attribute => isValidAttributeLocalName(local_name), + }; + if (!local_valid) { + return error.InvalidCharacterError; + } + + if (prefix != null and namespace == null) { + return error.NamespaceError; + } + if (prefix) |p| { + if (std.mem.eql(u8, p, "xml") and (namespace == null or !std.mem.eql(u8, namespace.?, "http://www.w3.org/XML/1998/namespace"))) { + return error.NamespaceError; + } + } + const is_xmlns = std.mem.eql(u8, qualified_name, "xmlns") or (prefix != null and std.mem.eql(u8, prefix.?, "xmlns")); + const ns_is_xmlns = namespace != null and std.mem.eql(u8, namespace.?, "http://www.w3.org/2000/xmlns/"); + if (is_xmlns != ns_is_xmlns) { + return error.NamespaceError; + } + + return .{ .prefix = prefix, .local_name = local_name, .namespace = namespace }; } // When a frame's URL is about:blank, or as soon as a frame is diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 9edf06296..dc26368a8 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1505,6 +1505,14 @@ pub fn clone(self: *Element, deep: bool, frame: *Frame) !*Node { const tag_name = self.getTagNameDump(); const node = try Frame.node_factory.createElementNS(frame, self._namespace, tag_name, &self._attributes); + // A namespace outside the built-in set lives in a side table; the clone + // must report the same namespaceURI. + if (self._namespace == .unknown) { + if (frame._element_namespace_uris.get(self)) |uri| { + try frame._element_namespace_uris.put(frame.arena, node.as(Element), uri); + } + } + // Allow element-specific types to copy their runtime state _ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), deep, frame }) catch |err| { log.err(.dom, "element.clone.failed", .{ .err = err }); @@ -1916,7 +1924,9 @@ pub const JsApi = struct { fn _tagName(self: *Element, frame: *Frame) []const u8 { return self.getTagNameSpec(&frame.buf); } - pub const namespaceURI = bridge.accessor(Element.getNamespaceURI, null, .{}); + // the frame-aware variant returns the original URI for namespaces + // outside the built-in set instead of the placeholder + pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{}); pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{ .ce_reactions = true }); fn _innerText(self: *Element, frame: *Frame) ![]const u8 { diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index edf6a96ef..500d0aebd 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -288,17 +288,17 @@ pub const JsApi = struct { pub const download = bridge.accessor(Area.getDownload, Area.setDownload, .{ .ce_reactions = true }); pub const rel = bridge.accessor(Area.getRel, Area.setRel, .{ .ce_reactions = true }); pub const referrerPolicy = bridge.accessor(Area.getReferrerPolicy, Area.setReferrerPolicy, .{ .ce_reactions = true }); - pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); pub const toString = bridge.function(Area.getHref, .{}); - pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); - fn _getRelList(self: *Area, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - // relList is only valid for HTML elements - if (element._namespace != .html) { - return null; + pub const relList = bridge.accessor(struct{ + fn wrap(self: *Area, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { + const element = self.asElement(); + // relList is only valid for HTML elements + if (element._namespace != .html) { + return null; + } + return element.getRelList(frame); } - return element.getRelList(frame); - } + }.wrap, null, .{ .null_as_undefined = true }); }; const testing = @import("../../../../testing.zig"); From 8f26f1e4b7b1cb71c481d927765473e317d0c083 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 12:03:46 +0800 Subject: [PATCH 5/6] zig fmt --- src/browser/webapi/element/html/Area.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index 500d0aebd..4ce875fb4 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -289,7 +289,7 @@ pub const JsApi = struct { pub const rel = bridge.accessor(Area.getRel, Area.setRel, .{ .ce_reactions = true }); pub const referrerPolicy = bridge.accessor(Area.getReferrerPolicy, Area.setReferrerPolicy, .{ .ce_reactions = true }); pub const toString = bridge.function(Area.getHref, .{}); - pub const relList = bridge.accessor(struct{ + pub const relList = bridge.accessor(struct { fn wrap(self: *Area, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { const element = self.asElement(); // relList is only valid for HTML elements From 872580a7085167172d3284dc4f5adcf1f93bcc70 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 15 Jul 2026 12:39:10 +0800 Subject: [PATCH 6/6] Move logic out of JsAPI While we do sometimes write functions directly in the JsAPI bridge, this is usually limited to things that are clearly JS/v8 bridging related. None of the new getters fit this (admittedly loose) definition..they're accessors like any other. --- src/browser/webapi/AbortSignal.zig | 9 ++--- src/browser/webapi/element/html/Area.zig | 24 +++++++------ src/browser/webapi/element/html/IFrame.zig | 25 ++++++++------ src/browser/webapi/element/html/Link.zig | 40 ++++++++++++---------- src/browser/webapi/element/html/Output.zig | 39 ++++++++++++++++----- 5 files changed, 81 insertions(+), 56 deletions(-) diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 9b679cbb2..5ad2f9758 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -168,9 +168,9 @@ pub fn createAborted(reason_: ?js.Value, exec: *const Execution) !*AbortSignal { } pub fn createAny(signals_value: js.Value, exec: *const Execution) !*AbortSignal { - // A js.Value parameter (not a slice) so the argument is required — the - // bridge would treat a trailing slice as variadic and default it to - // empty, but AbortSignal.any() must throw when called with no argument. + // The parameter isn't optional. If we declared it as a slice directy, the + // bridge would treat it as a variadic and map it to empty rather than throwing + // a TypeError as it should. const signals = try signals_value.toZig([]const *AbortSignal); const result = try init(exec); @@ -284,9 +284,6 @@ pub const JsApi = struct { pub const Prototype = EventTarget; - // Per spec AbortSignal has no constructor; instances are created via the - // static methods and AbortController. (AbortSignal.init stays for - // internal use.) pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{}); pub const reason = bridge.accessor(AbortSignal.getReason, null, .{}); pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{}); diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index 4ce875fb4..02b01a708 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -16,10 +16,12 @@ const std = @import("std"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); - const URL = @import("../../../URL.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; + const HtmlElement = @import("../Html.zig"); const Area = @This(); @@ -248,6 +250,15 @@ pub fn setProtocol(self: *Area, value: []const u8, frame: *Frame) !void { try setHref(self, new_href, frame); } +pub fn getRelList(self: *Area, frame: *Frame) !?*DOMTokenList { + const element = self.asElement(); + // relList is only valid for HTML elements + if (element._namespace != .html) { + return null; + } + return element.getRelList(frame); +} + fn getResolvedHref(self: *Area, frame: *Frame) !?[:0]const u8 { const href = self.asElement().getAttributeSafe(comptime .wrap("href")) orelse return null; if (href.len == 0) { @@ -289,16 +300,7 @@ pub const JsApi = struct { pub const rel = bridge.accessor(Area.getRel, Area.setRel, .{ .ce_reactions = true }); pub const referrerPolicy = bridge.accessor(Area.getReferrerPolicy, Area.setReferrerPolicy, .{ .ce_reactions = true }); pub const toString = bridge.function(Area.getHref, .{}); - pub const relList = bridge.accessor(struct { - fn wrap(self: *Area, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - // relList is only valid for HTML elements - if (element._namespace != .html) { - return null; - } - return element.getRelList(frame); - } - }.wrap, null, .{ .null_as_undefined = true }); + pub const relList = bridge.accessor(Area.getRelList, null, .{ .null_as_undefined = true }); }; const testing = @import("../../../../testing.zig"); diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index 5e2153b76..e2e139049 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -20,10 +20,13 @@ const std = @import("std"); const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); -const Window = @import("../../Window.zig"); -const Document = @import("../../Document.zig"); + const Node = @import("../../Node.zig"); +const Window = @import("../../Window.zig"); const Element = @import("../../Element.zig"); +const Document = @import("../../Document.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; + const HtmlElement = @import("../Html.zig"); const IFrame = @This(); @@ -80,6 +83,14 @@ pub fn setName(self: *IFrame, value: []const u8, frame: *Frame) !void { try self.asElement().setAttributeSafe(comptime .wrap("name"), .wrap(value), frame); } +pub fn getSandbox(self: *IFrame, frame: *Frame) !?*DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.sandbox, frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(IFrame); @@ -93,15 +104,7 @@ pub const JsApi = struct { pub const name = bridge.accessor(IFrame.getName, IFrame.setName, .{ .ce_reactions = true }); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); - pub const sandbox = bridge.accessor(_getSandbox, null, .{ .null_as_undefined = true }); - - fn _getSandbox(self: *IFrame, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - if (element._namespace != .html) { - return null; - } - return element.getTokenList(.sandbox, frame); - } + pub const sandbox = bridge.accessor(IFrame.getSandbox, null, .{ .null_as_undefined = true }); }; pub const Build = struct { diff --git a/src/browser/webapi/element/html/Link.zig b/src/browser/webapi/element/html/Link.zig index 06eee926d..2c06d3d8b 100644 --- a/src/browser/webapi/element/html/Link.zig +++ b/src/browser/webapi/element/html/Link.zig @@ -22,6 +22,8 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; + const HtmlElement = @import("../Html.zig"); const Link = @This(); @@ -187,6 +189,23 @@ pub fn setTarget(self: *Link, value: []const u8, frame: *Frame) !void { return self.asElement().setAttributeSafe(comptime .wrap("target"), .wrap(value), frame); } +pub fn getSizes(self: *Link, frame: *Frame) !?*DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.sizes, frame); +} + +pub fn getRelList(self: *Link, frame: *Frame) !?*DOMTokenList { + const element = self.asElement(); + // relList is only valid for HTML elements, not SVG or MathML + if (element._namespace != .html) { + return null; + } + return element.getRelList(frame); +} + pub fn linkAddedCallback(self: *Link, frame: *Frame) !void { // if we're planning on navigating to another frame, don't trigger load event. if (frame.isGoingAway()) { @@ -255,25 +274,8 @@ pub const JsApi = struct { pub const @"type" = bridge.accessor(Link.getType, Link.setType, .{ .ce_reactions = true }); pub const rev = bridge.accessor(Link.getRev, Link.setRev, .{ .ce_reactions = true }); pub const target = bridge.accessor(Link.getTarget, Link.setTarget, .{ .ce_reactions = true }); - pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true }); - pub const sizes = bridge.accessor(_getSizes, null, .{ .null_as_undefined = true }); - - fn _getSizes(self: *Link, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - if (element._namespace != .html) { - return null; - } - return element.getTokenList(.sizes, frame); - } - - fn _getRelList(self: *Link, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - // relList is only valid for HTML elements, not SVG or MathML - if (element._namespace != .html) { - return null; - } - return element.getRelList(frame); - } + pub const relList = bridge.accessor(Link.getRelList, null, .{ .null_as_undefined = true }); + pub const sizes = bridge.accessor(Link.getSizes, null, .{ .null_as_undefined = true }); }; // Parser-created elements are void (no closing tag) so they never diff --git a/src/browser/webapi/element/html/Output.zig b/src/browser/webapi/element/html/Output.zig index 96240077e..449f84ec6 100644 --- a/src/browser/webapi/element/html/Output.zig +++ b/src/browser/webapi/element/html/Output.zig @@ -1,7 +1,28 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); + const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; + const HtmlElement = @import("../Html.zig"); const Output = @This(); @@ -19,6 +40,14 @@ pub fn getLabels(self: *Output, frame: *Frame) !js.Array { return @import("Label.zig").getControlLabels(self.asElement(), frame); } +pub fn getHtmlFor(self: *Output, frame: *Frame) !?*DOMTokenList { + const element = self.asElement(); + if (element._namespace != .html) { + return null; + } + return element.getTokenList(.@"for", frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Output); @@ -29,13 +58,5 @@ pub const JsApi = struct { }; pub const labels = bridge.accessor(Output.getLabels, null, .{}); - pub const htmlFor = bridge.accessor(_getHtmlFor, null, .{ .null_as_undefined = true }); - - fn _getHtmlFor(self: *Output, frame: *Frame) !?*@import("../../collections.zig").DOMTokenList { - const element = self.asElement(); - if (element._namespace != .html) { - return null; - } - return element.getTokenList(.@"for", frame); - } + pub const htmlFor = bridge.accessor(Output.getHtmlFor, null, .{ .null_as_undefined = true }); };