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");