+//
+// 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 Frame = @import("../Frame.zig");
+const Parser = @import("../parser/Parser.zig");
+
+const Node = @import("../webapi/Node.zig");
+const Element = @import("../webapi/Element.zig");
+const Document = @import("../webapi/Document.zig");
+const ShadowRoot = @import("../webapi/ShadowRoot.zig");
+const slotting = @import("../webapi/element/slotting.zig");
+
+pub fn htmlAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
+ return htmlAsChildrenInner(frame, node, html, .{});
+}
+
+// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node
+pub fn htmlUnsafeAsChildren(frame: *Frame, node: *Node, html: []const u8) !void {
+ return htmlAsChildrenInner(frame, node, html, .{ .allow_declarative_shadow = true });
+}
+
+// Range.createContextualFragment variant: unlike innerHTML et al., its scripts
+// are run when the fragment is inserted into a document.
+pub fn contextualFragment(frame: *Frame, node: *Node, html: []const u8) !void {
+ return htmlAsChildrenInner(frame, node, html, .{ .scripts_runnable = true });
+}
+
+const FragmentParseOpts = struct {
+ scripts_runnable: bool = false,
+ allow_declarative_shadow: bool = false,
+};
+
+fn htmlAsChildrenInner(frame: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void {
+ const previous_parse_mode = frame._parse_mode;
+ frame._parse_mode = .fragment;
+ defer frame._parse_mode = previous_parse_mode;
+
+ // The html5ever wrapper-unwrap below rebinds children without going
+ // through the insertion path, so recompute slot assignments for any
+ // shadow tree this fragment landed in (idempotent; signals only on diff).
+ defer if (frame._element_shadow_roots.count() != 0) {
+ const root = node.getRootNode(.{});
+ if (root.is(ShadowRoot) != null) {
+ slotting.assignSlottablesForTree(root, frame);
+ }
+ if (node.is(Element)) |el| {
+ if (frame._element_shadow_roots.get(el)) |shadow_root| {
+ slotting.assignSlottablesForTree(shadow_root.asNode(), frame);
+ }
+ }
+ };
+
+ const previous_scripts_runnable = frame._fragment_scripts_runnable;
+ frame._fragment_scripts_runnable = opts.scripts_runnable;
+ defer frame._fragment_scripts_runnable = previous_scripts_runnable;
+
+ var parser = Parser.init(frame.call_arena, node, frame, .{ .allow_declarative_shadow = opts.allow_declarative_shadow });
+ parser.parseFragment(html);
+ if (parser.terminated) {
+ return error.ExecutionTerminated;
+ }
+
+ // html5ever wraps fragment output in an element; unwrap so its
+ // children land directly on `node`. See https://github.com/servo/html5ever/issues/583.
+ // Because of custom element callbacks, the structure might not be what
+ // we expect, and nodes might be altogether removed. We deal with this in a
+ // few different places, but always the same way: leave it as-is.
+ const children = node._children orelse return;
+ const first = Node.linkToNode(children.first.?);
+ if (first.is(Element.Html.Html) == null) {
+ return;
+ }
+ node._children = first._children;
+
+ // No mutation records for the unwrapped children either; see the comment
+ // about fragment parses in _insertNodeRelative.
+ var it = node.childrenIterator();
+ while (it.next()) |child| {
+ child._parent = node;
+ }
+}
+
+// Build a detached XMLDocument from `xml` (DOMParser.parseFromString and
+// XMLHttpRequest.responseXML). Returns null when the input isn't well-formed
+// XML.
+pub fn xmlDocument(frame: *Frame, xml: []const u8) !?*Document.XMLDocument {
+ const arena = try frame.getArena(.medium, "parse.xmlDocument");
+ defer frame.releaseArena(arena);
+
+ const previous_parse_mode = frame._parse_mode;
+ frame._parse_mode = .fragment;
+ defer frame._parse_mode = previous_parse_mode;
+
+ const doc = try frame._factory.document(Document.XMLDocument{ ._proto = undefined });
+ const doc_node = doc.asNode();
+ var parser = Parser.init(arena, doc_node, frame, .{});
+ parser.parseXML(xml);
+ if (parser.terminated) {
+ return error.ExecutionTerminated;
+ }
+
+ if (parser.err != null or doc_node.firstChild() == null) {
+ return null;
+ }
+
+ // If first node is a `ProcessingInstruction` (e.g. the
+ // declaration), skip it.
+ const first_child = doc_node.firstChild().?;
+ if (first_child.getNodeType() == 7) {
+ _ = try doc_node.removeChild(first_child, frame);
+ }
+
+ return doc;
+}
diff --git a/src/browser/interactive.zig b/src/browser/interactive.zig
index 8c9c51e31..d6f092842 100644
--- a/src/browser/interactive.zig
+++ b/src/browser/interactive.zig
@@ -501,7 +501,7 @@ fn testInteractive(html: []const u8) ![]InteractiveElement {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), html);
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectInteractiveElements(div.asNode(), frame.call_arena, frame);
}
diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig
index f8c5163e8..0641a06ab 100644
--- a/src/browser/js/Context.zig
+++ b/src/browser/js/Context.zig
@@ -1093,6 +1093,17 @@ pub fn queueIntersectionDelivery(self: *Context) !void {
}.run);
}
+pub fn queueResizeChecks(self: *Context) !void {
+ self.enqueueMicrotask(struct {
+ fn run(ctx: *Context) void {
+ switch (ctx.global) {
+ .frame => |frame| Frame.observers.performScheduledResizeChecks(frame),
+ .worker => unreachable,
+ }
+ }
+ }.run);
+}
+
pub fn queueResizeDelivery(self: *Context) !void {
self.enqueueMicrotask(struct {
fn run(ctx: *Context) void {
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index cdb6c021d..b0c13794e 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -1074,8 +1074,13 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/svg/Angle.zig"),
@import("../webapi/svg/Transform.zig"),
@import("../webapi/svg/AnimatedLength.zig"),
+ @import("../webapi/svg/AnimatedNumber.zig"),
@import("../webapi/svg/PreserveAspectRatio.zig"),
@import("../webapi/svg/AnimatedPreserveAspectRatio.zig"),
+ @import("../webapi/svg/PointList.zig"),
+ @import("../webapi/svg/TransformList.zig"),
+ @import("../webapi/svg/AnimatedTransformList.zig"),
+ @import("../webapi/svg/StringList.zig"),
@import("../webapi/encoding/TextDecoder.zig"),
@import("../webapi/encoding/TextEncoder.zig"),
@import("../webapi/encoding/TextEncoderStream.zig"),
diff --git a/src/browser/links.zig b/src/browser/links.zig
index 45695923a..95624ce0f 100644
--- a/src/browser/links.zig
+++ b/src/browser/links.zig
@@ -94,7 +94,7 @@ fn testLinks(html: []const u8) ![]Link {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), html);
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectLinks(frame.call_arena, div.asNode(), frame);
}
diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig
index ae702e2d4..86cdd45f4 100644
--- a/src/browser/markdown.zig
+++ b/src/browser/markdown.zig
@@ -599,7 +599,7 @@ fn testMarkdownHTML(html: []const u8, expected: []const u8) !void {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), html);
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -800,7 +800,7 @@ test "browser.markdown: resolve links" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(),
+ try Frame.parse.htmlAsChildren(frame, div.asNode(),
\\Link
\\
\\Space
@@ -839,7 +839,7 @@ test "browser.markdown: max_bytes leaves output untouched when under cap" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), "Short
");
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), "Short
");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -855,7 +855,7 @@ test "browser.markdown: max_bytes truncates with marker" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), "" ++ ("AAAA " ** 100) ++ "
");
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), "" ++ ("AAAA " ** 100) ++ "
");
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
@@ -878,11 +878,11 @@ fn testMarkdownShadow(light: []const u8, shadow: []const u8, expected: []const u
const host = try doc.createElement("div", null, frame);
if (light.len > 0) {
- try frame.parseHtmlAsChildren(host.asNode(), light);
+ try Frame.parse.htmlAsChildren(frame, host.asNode(), light);
}
const sr = try host.attachShadow(.{ .mode = .open }, frame);
- try frame.parseHtmlAsChildren(sr.asNode(), shadow);
+ try Frame.parse.htmlAsChildren(frame, sr.asNode(), shadow);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig
index d252aa9db..b95e4715b 100644
--- a/src/browser/parser/Parser.zig
+++ b/src/browser/parser/Parser.zig
@@ -28,6 +28,7 @@ const CData = @import("../webapi/CData.zig");
pub const AttributeIterator = h5e.AttributeIterator;
const Allocator = std.mem.Allocator;
+const TERMINATE_CHECK_INTERVAL = 1024;
const IS_DEBUG = @import("builtin").mode == .Debug;
pub const ParsedNode = struct {
@@ -76,6 +77,9 @@ buf: std.ArrayList(u8),
// innerHTML and DOMParser (per spec). Set from Options at init.
allow_declarative_shadow: bool = false,
+terminated: bool = false,
+appends_until_terminate_check: u16 = TERMINATE_CHECK_INTERVAL,
+
pub const Options = struct {
allow_declarative_shadow: bool = false,
};
@@ -428,6 +432,9 @@ fn parseErrorCallback(ctx: *anyopaque, err: h5e.StringSlice) callconv(.c) void {
fn popCallback(ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) {
+ return;
+ }
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._popCallback(getNode(node_ref)) catch |err| {
@@ -535,6 +542,9 @@ fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const
fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) {
+ return;
+ }
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| {
@@ -559,6 +569,9 @@ fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const
fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) {
+ return;
+ }
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._addAttrsIfMissingCallback(getNode(target_ref), attributes) catch |err| {
@@ -607,6 +620,7 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque {
// fall back to inserting the template as a normal light-DOM element.
fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, template_ref: *anyopaque, mode_is_open: u8) callconv(.c) u8 {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) return 0;
return self._attachDeclarativeShadowCallback(getNode(host_ref), getNode(template_ref), mode_is_open != 0) catch |err| {
self.err = .{ .err = err, .source = .attach_declarative_shadow };
return 0;
@@ -643,6 +657,10 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque {
fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.pollTerminate()) {
+ return;
+ }
+
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| {
@@ -677,6 +695,9 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) !
fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) {
+ return;
+ }
const cp = self.frame._ce_reactions.push();
defer self.frame._ce_reactions.popAndInvoke(cp, self.frame);
self._removeFromParentCallback(getNode(target_ref)) catch |err| {
@@ -695,6 +716,9 @@ fn _removeFromParentCallback(self: *Parser, node: *Node) !void {
fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.terminated) {
+ return;
+ }
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| {
@@ -711,6 +735,10 @@ fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !voi
fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.pollTerminate()) {
+ return;
+ }
+
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| {
@@ -741,6 +769,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e
fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void {
const self: *Parser = @ptrCast(@alignCast(ctx));
+ if (self.pollTerminate()) {
+ return;
+ }
+
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| {
@@ -772,3 +804,23 @@ fn asUint(comptime string: anytype) std.meta.Int(
return @bitCast(@as(*const [byteLength]u8, string).*);
}
+
+// v8's terminate isn't pre-emptive. A parse of unbounded input
+// (this.innerHTML += this.innerHTML) has to poll the terminate flag itself.
+// We'll poll this (atomic) variable every TERMINATE_CHECK_INTERVAL append.
+fn pollTerminate(self: *Parser) bool {
+ if (self.terminated) {
+ return true;
+ }
+
+ const next_check = self.appends_until_terminate_check - 1;
+ if (next_check > 0) {
+ self.appends_until_terminate_check = next_check;
+ return false;
+ }
+ self.appends_until_terminate_check = TERMINATE_CHECK_INTERVAL;
+
+ const terminated = self.frame.js.env.terminatePending();
+ self.terminated = terminated;
+ return terminated;
+}
diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig
index 8e5aac614..0090f81eb 100644
--- a/src/browser/structured_data.zig
+++ b/src/browser/structured_data.zig
@@ -494,7 +494,7 @@ fn testStructuredData(html: []const u8) !StructuredData {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), html);
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), html);
return collectStructuredData(div.asNode(), frame.call_arena, frame);
}
@@ -714,7 +714,7 @@ test "structured_data: link headers from response" {
const doc = frame.window._document;
const div = try doc.createElement("div", null, frame);
- try frame.parseHtmlAsChildren(div.asNode(), "Example");
+ try Frame.parse.htmlAsChildren(frame, div.asNode(), "Example");
const data = try collectStructuredData(div.asNode(), frame.call_arena, frame);
diff --git a/src/browser/tests/cdp/worker_network.html b/src/browser/tests/cdp/worker_network.html
new file mode 100644
index 000000000..0863bd183
--- /dev/null
+++ b/src/browser/tests/cdp/worker_network.html
@@ -0,0 +1,4 @@
+
+
diff --git a/src/browser/tests/cdp/worker_network.js b/src/browser/tests/cdp/worker_network.js
new file mode 100644
index 000000000..432ab3761
--- /dev/null
+++ b/src/browser/tests/cdp/worker_network.js
@@ -0,0 +1 @@
+fetch("/echo_method");
diff --git a/src/browser/tests/custom_elements/parser_wrapper_removed.html b/src/browser/tests/custom_elements/parser_wrapper_removed.html
index 77c637eb5..c2ca9f62b 100644
--- a/src/browser/tests/custom_elements/parser_wrapper_removed.html
+++ b/src/browser/tests/custom_elements/parser_wrapper_removed.html
@@ -2,7 +2,7 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html
index e8137bbad..3022384c9 100644
--- a/src/browser/tests/element/position.html
+++ b/src/browser/tests/element/position.html
@@ -24,12 +24,206 @@
{
const test1 = $('#test1');
- // In dummy layout, scroll dimensions equal client dimensions (no overflow)
+ // Both scroll dimensions are approximated from the content, so neither falls
+ // below the element's own box but either may exceed it.
+ testing.expectTrue(test1.scrollWidth >= test1.clientWidth);
+ testing.expectTrue(test1.scrollHeight >= test1.clientHeight);
+
+ // test1 holds only a text node, and text is not measured on either axis, so
+ // both still match the element's own box.
testing.expectEqual(test1.clientWidth, test1.scrollWidth);
testing.expectEqual(test1.clientHeight, test1.scrollHeight);
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/svg/geometry.html b/src/browser/tests/element/svg/geometry.html
new file mode 100644
index 000000000..e3d67b71f
--- /dev/null
+++ b/src/browser/tests/element/svg/geometry.html
@@ -0,0 +1,212 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/element/svg/hierarchy.html b/src/browser/tests/element/svg/hierarchy.html
index dac451853..441b94b4b 100644
--- a/src/browser/tests/element/svg/hierarchy.html
+++ b/src/browser/tests/element/svg/hierarchy.html
@@ -38,6 +38,7 @@
testing.expectEqual('function', typeof SVGPolylineElement);
testing.expectEqual('function', typeof SVGAnimatedString);
testing.expectEqual('function', typeof SVGNumber);
+ testing.expectEqual('function', typeof SVGAnimatedNumber);
}
diff --git a/src/browser/tests/net/fetch.html b/src/browser/tests/net/fetch.html
index f624d8ac8..fdd13dbb2 100644
--- a/src/browser/tests/net/fetch.html
+++ b/src/browser/tests/net/fetch.html
@@ -348,6 +348,93 @@
}
+
+
+
+
+
+
diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html
index 3f45b3715..0025e8966 100644
--- a/src/browser/tests/net/xhr.html
+++ b/src/browser/tests/net/xhr.html
@@ -372,9 +372,30 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/worker/api-worker.js b/src/browser/tests/worker/api-worker.js
index 1f75d7e1c..803b8ace1 100644
--- a/src/browser/tests/worker/api-worker.js
+++ b/src/browser/tests/worker/api-worker.js
@@ -59,6 +59,11 @@
const loc_pathname = loc.pathname;
const loc_to_string = String(loc);
+ // DOMPoint mutation (setters must not require a Frame in a worker)
+ const point = new DOMPoint(1, 2);
+ point.x = 5;
+ point.w = 0.5;
+
postMessage({
ok: true,
results: {
@@ -90,6 +95,8 @@
loc_protocol,
loc_pathname,
loc_to_string,
+ point_x: point.x,
+ point_w: point.w,
},
});
} catch (e) {
diff --git a/src/browser/tests/worker/worker.html b/src/browser/tests/worker/worker.html
index cd3512535..2b953aff1 100644
--- a/src/browser/tests/worker/worker.html
+++ b/src/browser/tests/worker/worker.html
@@ -247,6 +247,10 @@
testing.expectEqual(r.loc_href, r.loc_to_string);
testing.expectEqual('http:', r.loc_protocol);
testing.expectTrue(r.loc_pathname.endsWith('api-worker.js'));
+
+ // DOMPoint
+ testing.expectEqual(5, r.point_x);
+ testing.expectEqual(0.5, r.point_w);
});
}
diff --git a/src/browser/tools.zig b/src/browser/tools.zig
index 6e295d8a1..73f2ee8bb 100644
--- a/src/browser/tools.zig
+++ b/src/browser/tools.zig
@@ -22,6 +22,7 @@ const zenai = @import("zenai");
const log = lp.log;
const tavily = zenai.search.tavily;
+const brave = zenai.search.brave;
const DOMNode = @import("webapi/Node.zig");
const CDPNode = @import("../cdp/Node.zig");
@@ -335,7 +336,7 @@ pub const Tool = enum {
),
},
.search => .{
- .description = "Run a web search and return results as markdown. When TAVILY_API_KEY is set, queries the Tavily Search API and returns a numbered list of {title, url, snippet}. Otherwise (or on Tavily failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.",
+ .description = "Run a web search and return results as markdown. When BRAVE_API_KEY or TAVILY_API_KEY is set, queries that search API (Brave preferred) and returns a numbered list of {title, url, snippet}. Otherwise (or on API failure) falls back to scraping the DuckDuckGo HTML endpoint — degraded results, may rate-limit on bursty traffic. Prefer this over goto-ing google.com/search directly (Google blocks the browser on User-Agent/TLS). Browser state after this call is unspecified — to interact with a result, use `goto` with its URL; do not assume the browser DOM matches the results page.",
.summary = "Web search, results as markdown",
.input_schema = minify(
\\{
@@ -814,7 +815,7 @@ fn dispatch(
) ToolError!ToolResult {
return switch (tool) {
.goto => .{ .text = try execGoto(arena, session, registry, substituted) },
- .search => .{ .text = try execSearch(arena, session, registry, substituted) },
+ .search => execSearch(arena, session, registry, substituted),
.markdown => .{ .text = try execMarkdown(arena, session, registry, substituted) },
.html => .{ .text = try execHtml(arena, session, registry, substituted) },
.links => .{ .text = try execLinks(arena, session, registry, substituted) },
@@ -946,20 +947,38 @@ pub const SearchParams = struct {
timeout: ?u32 = null,
};
-fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError![]const u8 {
+/// Search backend for `execSearch`. `.auto` tries Brave then Tavily (each
+/// only when its API key is set), then the DuckDuckGo scrape; an explicit
+/// engine is used alone. Only the agent REPL's `/searchEngine` mutates this.
+pub const SearchEngine = enum { auto, tavily, brave, duckduckgo };
+pub var search_engine: SearchEngine = .auto;
+
+fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode.Registry, arguments: ?std.json.Value) ToolError!ToolResult {
const args = try parseArgs(SearchParams, arena, arguments);
if (args.query.len == 0) return ToolError.InvalidParams;
- // Tavily path: only when TAVILY_API_KEY is set in the process env. On any
- // failure (network, non-2xx, parse) fall through to the DuckDuckGo scrape
- // so a single Tavily outage doesn't kill a whole benchmark run.
- if (std.c.getenv("TAVILY_API_KEY")) |api_key_z| {
- const api_key = std.mem.span(api_key_z);
- if (tavilySearch(arena, api_key, args.query)) |markdown_| {
- return markdown_;
- } else |err| {
- log.warn(.browser, "tavily fallback", .{ .err = err });
- }
+ switch (search_engine) {
+ // Any failure (network, non-2xx, parse) falls through to the next
+ // engine so a single outage doesn't kill a whole benchmark run.
+ .auto => {
+ if (std.c.getenv("BRAVE_API_KEY")) |api_key_z| {
+ if (braveSearch(arena, std.mem.span(api_key_z), args.query)) |markdown_| {
+ return .{ .text = markdown_ };
+ } else |err| {
+ log.warn(.browser, "brave fallback", .{ .err = err });
+ }
+ }
+ if (std.c.getenv("TAVILY_API_KEY")) |api_key_z| {
+ if (tavilySearch(arena, std.mem.span(api_key_z), args.query)) |markdown_| {
+ return .{ .text = markdown_ };
+ } else |err| {
+ log.warn(.browser, "tavily fallback", .{ .err = err });
+ }
+ }
+ },
+ .tavily => return searchExplicit(arena, "tavily", "TAVILY_API_KEY", tavilySearch, args.query),
+ .brave => return searchExplicit(arena, "brave", "BRAVE_API_KEY", braveSearch, args.query),
+ .duckduckgo => {},
}
const encoded = lp.URL.percentEncodeSegment(arena, args.query, .component) catch return ToolError.OutOfMemory;
@@ -971,7 +990,28 @@ fn execSearch(arena: std.mem.Allocator, session: *lp.Session, registry: *CDPNode
) catch return ToolError.OutOfMemory;
_ = try performGoto(session, registry, ddg_url, args.timeout);
const ddg_frame = try requireFrame(session);
- return renderFrameMarkdown(arena, ddg_frame);
+ return .{ .text = try renderFrameMarkdown(arena, ddg_frame) };
+}
+
+/// Search with an explicitly selected engine: a missing key or a failed call
+/// comes back as an error result — no DDG fallback.
+fn searchExplicit(
+ arena: std.mem.Allocator,
+ comptime label: []const u8,
+ comptime env_var: [:0]const u8,
+ searchFn: anytype,
+ query: []const u8,
+) ToolError!ToolResult {
+ const api_key_z = std.c.getenv(env_var) orelse return .{
+ .text = "web search engine is set to " ++ label ++ " but " ++ env_var ++ " is not set in the environment",
+ .is_error = true,
+ };
+ const markdown_ = searchFn(arena, std.mem.span(api_key_z), query) catch |err| {
+ const msg = std.fmt.allocPrint(arena, label ++ " search failed: {s}", .{@errorName(err)}) catch
+ return ToolError.OutOfMemory;
+ return .{ .text = msg, .is_error = true };
+ };
+ return .{ .text = markdown_ };
}
/// Thin wrapper over `zenai.search.tavily.Client` that handles client
@@ -982,40 +1022,97 @@ fn tavilySearch(
api_key: []const u8,
query: []const u8,
) ![]const u8 {
- var client: tavily.Client = .init(arena, lp.io, api_key, .{});
+ var client: tavily.Client = .init(lp.io, arena, api_key, .{});
defer client.deinit();
var response = client.search(query, .{ .max_results = 10 }) catch |err| {
if (client.last_error_status) |status| {
log.warn(.browser, "tavily non-2xx", .{
.status = status,
- .body = client.last_error_body orelse "",
+ .body = client.last_error_body,
});
}
return err;
};
defer response.deinit();
- return formatTavilyMarkdown(arena, response.value);
+ var aw: std.Io.Writer.Allocating = .init(arena);
+ try formatTavilyMarkdown(&aw.writer, response.value);
+ return aw.written();
}
-fn formatTavilyMarkdown(arena: std.mem.Allocator, resp: tavily.types.SearchResponse) ![]const u8 {
- var aw: std.Io.Writer.Allocating = .init(arena);
- const w = &aw.writer;
- if (resp.answer) |a| {
- if (a.len > 0) {
- try w.print("**Answer:** {s}\n\n", .{a});
- }
+fn formatTavilyMarkdown(w: *std.Io.Writer, resp: tavily.types.SearchResponse) !void {
+ const answer = resp.answer orelse "";
+ if (answer.len == 0 and resp.results.len == 0) {
+ return w.writeAll("No results.");
+ }
+ if (answer.len > 0) {
+ try w.print("**Answer:** {s}\n\n", .{answer});
}
for (resp.results, 0..) |r, i| {
- try w.print("{d}. **{s}** — {s}\n {s}\n\n", .{ i + 1, r.title, r.url, r.content });
- }
- if (resp.results.len == 0 and (resp.answer == null or resp.answer.?.len == 0)) {
- try w.writeAll("No results.");
+ try writeResultItem(w, i, r.title, r.url, r.content);
}
+}
+
+/// Thin wrapper over `zenai.search.brave.Client` that handles client
+/// lifetime and renders the structured response as markdown for the agent.
+/// `arena` owns the returned slice. `api_key` is the value of BRAVE_API_KEY.
+fn braveSearch(
+ arena: std.mem.Allocator,
+ api_key: []const u8,
+ query: []const u8,
+) ![]const u8 {
+ var client: brave.Client = .init(lp.io, arena, api_key, .{});
+ defer client.deinit();
+
+ // text_decorations=false: no markup in model-read snippets.
+ var response = client.search(query, .{ .count = 10, .text_decorations = false }) catch |err| {
+ if (client.last_error_status) |status| {
+ log.warn(.browser, "brave non-2xx", .{
+ .status = status,
+ .body = client.last_error_body,
+ });
+ }
+ return err;
+ };
+ defer response.deinit();
+
+ var aw: std.Io.Writer.Allocating = .init(arena);
+ try formatBraveMarkdown(&aw.writer, response.value);
return aw.written();
}
+fn formatBraveMarkdown(w: *std.Io.Writer, resp: brave.types.SearchResponse) !void {
+ const results: []const brave.types.Result = if (resp.web) |web| web.results else &.{};
+ if (results.len == 0) {
+ return w.writeAll("No results.");
+ }
+ for (results, 0..) |r, i| {
+ try writeResultItem(w, i, r.title, r.url, r.description);
+ }
+}
+
+fn writeResultItem(w: *std.Io.Writer, i: usize, title: []const u8, url: []const u8, snippet: []const u8) !void {
+ try w.print("{d}. **", .{i + 1});
+ try writeSingleLine(w, title);
+ try w.print("** — {s}\n ", .{url});
+ try writeSingleLine(w, snippet);
+ try w.writeAll("\n\n");
+}
+
+/// Newlines in API-provided text would break the numbered-list markdown.
+fn writeSingleLine(w: *std.Io.Writer, text: []const u8) !void {
+ var it = std.mem.tokenizeAny(u8, text, "\r\n");
+ var first = true;
+ while (it.next()) |chunk| {
+ if (!first) {
+ try w.writeByte(' ');
+ }
+ first = false;
+ try w.writeAll(chunk);
+ }
+}
+
fn renderFrameMarkdown(arena: std.mem.Allocator, frame: *lp.Frame) ToolError![]const u8 {
var aw: std.Io.Writer.Allocating = .init(arena);
lp.markdown.dump(frame.document.asNode(), .{}, &aw.writer, frame) catch
@@ -2220,7 +2317,9 @@ test "formatTavilyMarkdown renders answer and results" {
},
};
- const md = try formatTavilyMarkdown(aa, resp);
+ var aw: std.Io.Writer.Allocating = .init(aa);
+ try formatTavilyMarkdown(&aw.writer, resp);
+ const md = aw.written();
try std.testing.expect(std.mem.indexOf(u8, md, "**Answer:** Paris") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null);
try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null);
@@ -2233,9 +2332,64 @@ test "formatTavilyMarkdown handles empty results" {
defer arena.deinit();
const aa = arena.allocator();
- const resp: tavily.types.SearchResponse = .{};
- const md = try formatTavilyMarkdown(aa, resp);
- try std.testing.expectEqualStrings("No results.", md);
+ var aw: std.Io.Writer.Allocating = .init(aa);
+ try formatTavilyMarkdown(&aw.writer, .{});
+ try std.testing.expectEqualStrings("No results.", aw.written());
+}
+
+test "formatBraveMarkdown renders web results" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ const resp: brave.types.SearchResponse = .{
+ .web = .{
+ .results = &.{
+ .{ .title = "Paris - Wikipedia", .url = "https://en.wikipedia.org/wiki/Paris", .description = "Paris is the capital of France." },
+ .{ .title = "France", .url = "https://example.org/fr", .description = "Country in Western Europe." },
+ },
+ },
+ };
+
+ var aw: std.Io.Writer.Allocating = .init(aa);
+ try formatBraveMarkdown(&aw.writer, resp);
+ const md = aw.written();
+ try std.testing.expect(std.mem.indexOf(u8, md, "1. **Paris - Wikipedia**") != null);
+ try std.testing.expect(std.mem.indexOf(u8, md, "https://en.wikipedia.org/wiki/Paris") != null);
+ try std.testing.expect(std.mem.indexOf(u8, md, "Paris is the capital of France.") != null);
+ try std.testing.expect(std.mem.indexOf(u8, md, "2. **France**") != null);
+}
+
+test "formatBraveMarkdown handles empty results" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ var no_web: std.Io.Writer.Allocating = .init(aa);
+ try formatBraveMarkdown(&no_web.writer, .{});
+ try std.testing.expectEqualStrings("No results.", no_web.written());
+
+ var empty_web: std.Io.Writer.Allocating = .init(aa);
+ try formatBraveMarkdown(&empty_web.writer, .{ .web = .{} });
+ try std.testing.expectEqualStrings("No results.", empty_web.written());
+}
+
+test "formatBraveMarkdown flattens newlines in titles and descriptions" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const aa = arena.allocator();
+
+ const resp: brave.types.SearchResponse = .{
+ .web = .{
+ .results = &.{
+ .{ .title = "Multi\nline title", .url = "https://example.org", .description = "line one\r\n\r\nline two" },
+ },
+ },
+ };
+
+ var aw: std.Io.Writer.Allocating = .init(aa);
+ try formatBraveMarkdown(&aw.writer, resp);
+ try std.testing.expectEqualStrings("1. **Multi line title** — https://example.org\n line one line two\n\n", aw.written());
}
test "isPathSafe: relative paths without traversal are accepted" {
diff --git a/src/browser/webapi/DOMMatrix.zig b/src/browser/webapi/DOMMatrix.zig
index 132fa52a7..1e375c5f9 100644
--- a/src/browser/webapi/DOMMatrix.zig
+++ b/src/browser/webapi/DOMMatrix.zig
@@ -84,137 +84,158 @@ pub fn getF(self: *const DOMMatrix) f64 {
return self._proto._m[13];
}
-pub fn setA(self: *DOMMatrix, v: f64) void {
- self._proto._m[0] = v;
+pub fn setA(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(0, v);
}
-pub fn setB(self: *DOMMatrix, v: f64) void {
- self._proto._m[1] = v;
+pub fn setB(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(1, v);
}
-pub fn setC(self: *DOMMatrix, v: f64) void {
- self._proto._m[4] = v;
+pub fn setC(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(4, v);
}
-pub fn setD(self: *DOMMatrix, v: f64) void {
- self._proto._m[5] = v;
+pub fn setD(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(5, v);
}
-pub fn setE(self: *DOMMatrix, v: f64) void {
- self._proto._m[12] = v;
+pub fn setE(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(12, v);
}
-pub fn setF(self: *DOMMatrix, v: f64) void {
- self._proto._m[13] = v;
+pub fn setF(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(13, v);
}
-pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) *DOMMatrix {
+pub fn translateSelf(self: *DOMMatrix, tx_: ?f64, ty_: ?f64, tz_: ?f64) !*DOMMatrix {
const tz = tz_ orelse 0;
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
- if (tz != 0) p._is_2d = false;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.translationMatrix(tx_ orelse 0, ty_ orelse 0, tz));
+ if (tz != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
+pub fn scaleSelf(self: *DOMMatrix, sx_: ?f64, sy_: ?f64, sz_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const sx = sx_ orelse 1;
const sy = sy_ orelse sx;
const sz = sz_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
- const p = self._proto;
- var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
+ var state = self._proto.getState();
+ var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(sx, sy, sz));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
- p._m = m;
- if (sz != 1 or oz != 0) p._is_2d = false;
- return self;
+ state.matrix = m;
+ if (sz != 1 or oz != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) *DOMMatrix {
+pub fn scale3dSelf(self: *DOMMatrix, scale_: ?f64, ox_: ?f64, oy_: ?f64, oz_: ?f64) !*DOMMatrix {
const s = scale_ orelse 1;
const ox = ox_ orelse 0;
const oy = oy_ orelse 0;
const oz = oz_ orelse 0;
- const p = self._proto;
- var m = RO.multiplyMatrix(p._m, RO.translationMatrix(ox, oy, oz));
+ var state = self._proto.getState();
+ var m = RO.multiplyMatrix(state.matrix, RO.translationMatrix(ox, oy, oz));
m = RO.multiplyMatrix(m, RO.scaleMatrix(s, s, s));
m = RO.multiplyMatrix(m, RO.translationMatrix(-ox, -oy, -oz));
- p._m = m;
- if (s != 1) p._is_2d = false;
- return self;
+ state.matrix = m;
+ if (s != 1) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) *DOMMatrix {
- const p = self._proto;
+pub fn rotateSelf(self: *DOMMatrix, rx_: ?f64, ry_: ?f64, rz_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
if (ry_ == null and rz_ == null) {
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rx_ orelse 0, .deg)));
} else {
- p._m = RO.multiplyMatrix(p._m, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
- p._m = RO.multiplyMatrix(p._m, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
- p._is_2d = false;
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateXMatrix(RO.toRadians(rx_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateYMatrix(RO.toRadians(ry_ orelse 0, .deg)));
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(RO.toRadians(rz_ orelse 0, .deg)));
+ state.is_2d = false;
}
- return self;
+ return self.applyState(state);
}
-pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) *DOMMatrix {
+pub fn rotateFromVectorSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64) !*DOMMatrix {
const x = x_ orelse 0;
const y = y_ orelse 0;
const rad = if (x == 0 and y == 0) 0 else std.math.atan2(y, x);
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.rotateZMatrix(rad));
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.rotateZMatrix(rad));
+ return self.applyState(state);
}
-pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
- if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) p._is_2d = false;
- return self;
+pub fn rotateAxisAngleSelf(self: *DOMMatrix, x_: ?f64, y_: ?f64, z_: ?f64, angle_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.axisAngleMatrix(x_ orelse 0, y_ orelse 0, z_ orelse 0, RO.toRadians(angle_ orelse 0, .deg)));
+ if ((x_ orelse 0) != 0 or (y_ orelse 0) != 0) state.is_2d = false;
+ return self.applyState(state);
}
-pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
- return self;
+pub fn skewXSelf(self: *DOMMatrix, sx_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(RO.toRadians(sx_ orelse 0, .deg), 0));
+ return self.applyState(state);
}
-pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) *DOMMatrix {
- const p = self._proto;
- p._m = RO.multiplyMatrix(p._m, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
- return self;
+pub fn skewYSelf(self: *DOMMatrix, sy_: ?f64) !*DOMMatrix {
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, RO.skewMatrix(0, RO.toRadians(sy_ orelse 0, .deg)));
+ return self.applyState(state);
}
pub fn multiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
- const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
- p._m = RO.multiplyMatrix(p._m, other.m);
- p._is_2d = p._is_2d and other.is_2d;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(state.matrix, other.m);
+ state.is_2d = state.is_2d and other.is_2d;
+ return self.applyState(state);
}
pub fn preMultiplySelf(self: *DOMMatrix, other_: ?RO.DOMMatrixInit) !*DOMMatrix {
- const p = self._proto;
const other = try RO.fixupDict(other_ orelse .{});
- p._m = RO.multiplyMatrix(other.m, p._m);
- p._is_2d = p._is_2d and other.is_2d;
- return self;
+ var state = self._proto.getState();
+ state.matrix = RO.multiplyMatrix(other.m, state.matrix);
+ state.is_2d = state.is_2d and other.is_2d;
+ return self.applyState(state);
}
-pub fn invertSelf(self: *DOMMatrix) *DOMMatrix {
- const p = self._proto;
- if (RO.invertMatrix(p._m)) |v| {
- p._m = v;
+pub fn invertSelf(self: *DOMMatrix) !*DOMMatrix {
+ var state = self._proto.getState();
+ if (RO.invertMatrix(state.matrix)) |v| {
+ state.matrix = v;
} else {
- p._m = .{std.math.nan(f64)} ** 16;
- p._is_2d = false;
+ state.matrix = .{std.math.nan(f64)} ** 16;
+ state.is_2d = false;
}
- return self;
+ return self.applyState(state);
}
pub fn setMatrixValue(self: *DOMMatrix, transform: []const u8) !*DOMMatrix {
- var m = RO.identity();
- var is_2d = true;
- try RO.parseTransformList(transform, &m, &is_2d);
- self._proto._m = m;
- self._proto._is_2d = is_2d;
+ var state: RO.State = .{ .matrix = RO.identity(), .is_2d = true };
+ try RO.parseTransformList(transform, &state.matrix, &state.is_2d);
+ try self._proto.applyState(state);
+ return self;
+}
+
+fn setElement(self: *DOMMatrix, comptime index: usize, value: f64) !void {
+ var state = self._proto.getState();
+ state.matrix[index] = value;
+
+ // Assigning a z/w element a value other than its identity drops the
+ // 2D flag. Setting it back does not restore is2D.
+ switch (index) {
+ 2, 3, 6, 7, 8, 9, 11, 14 => if (value != 0) {
+ state.is_2d = false;
+ },
+ 10, 15 => if (value != 1) {
+ state.is_2d = false;
+ },
+ else => {},
+ }
+ try self._proto.applyState(state);
+}
+
+fn applyState(self: *DOMMatrix, state: RO.State) !*DOMMatrix {
+ try self._proto.applyState(state);
return self;
}
@@ -281,23 +302,10 @@ pub const JsApi = struct {
}.get;
}
- fn setM(comptime idx: usize) fn (*DOMMatrix, f64) void {
+ fn setM(comptime idx: usize) fn (*DOMMatrix, f64) anyerror!void {
return struct {
- fn set(self: *DOMMatrix, v: f64) void {
- self._proto._m[idx] = v;
- // Assigning a z/w element a value other than its identity drops the
- // 2D flag. Setting it back to the identity value (0 for the
- // off-diagonal elements, 1 for m33/m44) preserves is2D. Note `-0`
- // compares equal to `0`, so it preserves it too, per spec.
- switch (idx) {
- 2, 3, 6, 7, 8, 9, 11, 14 => if (v != 0) {
- self._proto._is_2d = false;
- },
- 10, 15 => if (v != 1) {
- self._proto._is_2d = false;
- },
- else => {},
- }
+ fn set(self: *DOMMatrix, v: f64) !void {
+ try self.setElement(idx, v);
}
}.set;
}
diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig
index d0340f942..e70658e25 100644
--- a/src/browser/webapi/DOMMatrixReadOnly.zig
+++ b/src/browser/webapi/DOMMatrixReadOnly.zig
@@ -44,12 +44,25 @@ _arena: Allocator,
// out[row] = sum_col _m[col*4 + row] * in[col]
_m: [16]f64,
_is_2d: bool,
+_attachment: ?Attachment = null,
pub const Type = union(enum) {
generic,
mutable: *DOMMatrix,
};
+pub const State = struct {
+ matrix: [16]f64,
+ is_2d: bool,
+};
+
+// The owner decides whether a write is allowed; a matrix that belongs to an
+// animVal SVGTransform is rejected by the transform, not here.
+pub const Attachment = struct {
+ owner: *anyopaque,
+ mutate: *const fn (*anyopaque, *DOMMatrixReadOnly, State) anyerror!void,
+};
+
pub fn init(init_: ?js.Value, exec: *const js.Execution) !*DOMMatrixReadOnly {
const parsed = try Parsed.init(init_, exec);
return createBare(parsed.m, parsed.is_2d, exec.page);
@@ -82,6 +95,29 @@ pub fn createBare(m: [16]f64, is_2d: bool, page: *Page) !*DOMMatrixReadOnly {
return self;
}
+pub fn getState(self: *const DOMMatrixReadOnly) State {
+ return .{
+ .matrix = self._m,
+ .is_2d = self._is_2d,
+ };
+}
+
+pub fn applyState(self: *DOMMatrixReadOnly, state: State) !void {
+ if (self._attachment) |attachment| {
+ return attachment.mutate(attachment.owner, self, state);
+ }
+ self.applyStateRaw(state);
+}
+
+pub fn applyStateRaw(self: *DOMMatrixReadOnly, state: State) void {
+ self._m = state.matrix;
+ self._is_2d = state.is_2d;
+}
+
+pub fn attach(self: *DOMMatrixReadOnly, attachment: Attachment) void {
+ self._attachment = attachment;
+}
+
pub const DOMMatrixInit = struct {
a: ?f64 = null,
b: ?f64 = null,
@@ -312,178 +348,274 @@ pub fn invertMatrix(m: [16]f64) ?[16]f64 {
return out;
}
+pub const TransformSyntax = enum { css, svg };
+
+pub const TransformKind = enum {
+ matrix,
+ matrix3d,
+ translate,
+ translate_x,
+ translate_y,
+ translate_z,
+ translate_3d,
+ scale,
+ scale_x,
+ scale_y,
+ scale_z,
+ scale_3d,
+ rotate,
+ rotate_x,
+ rotate_y,
+ rotate_z,
+ rotate_3d,
+ skew,
+ skew_x,
+ skew_y,
+ perspective,
+};
+
+pub const ParsedTransform = struct {
+ kind: TransformKind,
+ matrix: [16]f64,
+ values: [16]f64,
+ count: usize,
+ is_2d: bool,
+};
+
+pub const TransformFunction = struct {
+ name: []const u8,
+ arguments: []const u8,
+};
+
+pub const TransformFunctionIterator = struct {
+ input: []const u8,
+ index: usize = 0,
+ allow_comma: bool = false,
+
+ pub fn next(self: *TransformFunctionIterator) !?TransformFunction {
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return null;
+ if (self.input[self.index] == ',') return error.SyntaxError;
+
+ const name_start = self.index;
+ while (self.index < self.input.len and
+ (std.ascii.isAlphabetic(self.input[self.index]) or std.ascii.isDigit(self.input[self.index])))
+ {
+ self.index += 1;
+ }
+ if (self.index == name_start or self.index >= self.input.len or self.input[self.index] != '(') {
+ return error.SyntaxError;
+ }
+ const name = self.input[name_start..self.index];
+ self.index += 1;
+
+ const arguments_start = self.index;
+ while (self.index < self.input.len and self.input[self.index] != ')') {
+ if (self.input[self.index] == '(') return error.SyntaxError;
+ self.index += 1;
+ }
+ if (self.index == self.input.len) return error.SyntaxError;
+ const arguments = self.input[arguments_start..self.index];
+ self.index += 1;
+
+ var had_whitespace = false;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
+ had_whitespace = true;
+ self.index += 1;
+ }
+ if (self.index < self.input.len and self.input[self.index] == ',') {
+ if (!self.allow_comma) return error.SyntaxError;
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return error.SyntaxError;
+ } else if (self.index < self.input.len and !had_whitespace) {
+ return error.SyntaxError;
+ }
+ return .{ .name = name, .arguments = arguments };
+ }
+};
+
// Parses a CSS (e.g. "matrix(1,0,0,1,10,20) scale(2)") and
// accumulates it into `m`. "none"/empty leave the matrix as identity.
pub fn parseTransformList(input: []const u8, m: *[16]f64, is_2d: *bool) !void {
const trimmed = std.mem.trim(u8, input, " \t\r\n");
- if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) {
- return;
- }
+ if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return;
- var i: usize = 0;
- while (i < trimmed.len) {
- // skip whitespace and separating commas
- while (i < trimmed.len and (std.ascii.isWhitespace(trimmed[i]) or trimmed[i] == ',')) : (i += 1) {}
- if (i >= trimmed.len) {
- break;
- }
-
- const name_start = i;
- while (i < trimmed.len and trimmed[i] != '(') : (i += 1) {}
- if (i >= trimmed.len) {
- return error.SyntaxError;
- }
- const name = std.mem.trim(u8, trimmed[name_start..i], " \t\r\n");
-
- i += 1; // consume '('
- const args_start = i;
- while (i < trimmed.len and trimmed[i] != ')') : (i += 1) {}
- if (i >= trimmed.len) {
- return error.SyntaxError;
- }
- const args = trimmed[args_start..i];
- i += 1; // consume ')'
-
- const func = try parseFunction(name, args, is_2d);
- m.* = multiplyMatrix(m.*, func);
+ var iterator = TransformFunctionIterator{ .input = trimmed };
+ while (try iterator.next()) |function| {
+ const parsed = try parseTransformFunction(function, .css);
+ m.* = multiplyMatrix(m.*, parsed.matrix);
+ if (!parsed.is_2d) is_2d.* = false;
}
}
-fn parseFunction(name: []const u8, args: []const u8, is_2d: *bool) ![16]f64 {
- var nums: [16]f64 = undefined;
+pub fn parseTransformFunction(function: TransformFunction, syntax: TransformSyntax) !ParsedTransform {
+ var values: [16]f64 = undefined;
var units: [16]ParsedValue.Unit = undefined;
- var count: usize = 0;
-
- var it = std.mem.splitScalar(u8, args, ',');
- while (it.next()) |raw| {
- const tok = std.mem.trim(u8, raw, " \t\r\n");
- if (tok.len == 0) {
- continue;
- }
- if (count >= 16) {
- return error.SyntaxError;
- }
- const parsed = try ParsedValue.parse(tok);
- nums[count] = parsed.value;
- units[count] = parsed.unit;
- count += 1;
- }
-
+ const count = try parseArguments(function.arguments, syntax, &values, &units);
+ const name = function.name;
const Eql = std.mem.eql;
+
if (Eql(u8, name, "matrix")) {
- if (count != 6) {
- return error.SyntaxError;
- }
- return .{
- nums[0], nums[1], 0, 0,
- nums[2], nums[3], 0, 0,
- 0, 0, 1, 0,
- nums[4], nums[5], 0, 1,
- };
+ try requireCount(count, 6, 6);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.matrix, .{
+ values[0], values[1], 0, 0,
+ values[2], values[3], 0, 0,
+ 0, 0, 1, 0,
+ values[4], values[5], 0, 1,
+ }, values, count, true);
}
if (Eql(u8, name, "matrix3d")) {
- if (count != 16) {
- return error.SyntaxError;
- }
- is_2d.* = false;
- return nums;
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 16, 16);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.matrix3d, values, values, count, false);
}
if (Eql(u8, name, "translate")) {
- const tx = nums[0];
- const ty = if (count > 1) nums[1] else 0;
- return translationMatrix(tx, ty, 0);
+ try requireCount(count, 1, 2);
+ if (syntax == .svg) try requireUnitless(units[0..count]);
+ const ty = if (count == 2) values[1] else 0;
+ return makeParsedTransform(.translate, translationMatrix(values[0], ty, 0), values, count, true);
}
-
if (Eql(u8, name, "translateX")) {
- return translationMatrix(nums[0], 0, 0);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_x, translationMatrix(values[0], 0, 0), values, count, true);
}
-
if (Eql(u8, name, "translateY")) {
- return translationMatrix(0, nums[0], 0);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_y, translationMatrix(0, values[0], 0), values, count, true);
}
-
if (Eql(u8, name, "translateZ")) {
- is_2d.* = false;
- return translationMatrix(0, 0, nums[0]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ return makeParsedTransform(.translate_z, translationMatrix(0, 0, values[0]), values, count, false);
}
-
if (Eql(u8, name, "translate3d")) {
- is_2d.* = false;
- return translationMatrix(nums[0], nums[1], nums[2]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 3, 3);
+ return makeParsedTransform(.translate_3d, translationMatrix(values[0], values[1], values[2]), values, count, false);
}
if (Eql(u8, name, "scale")) {
- const sx = nums[0];
- const sy = if (count > 1) nums[1] else sx;
- return scaleMatrix(sx, sy, 1);
+ try requireCount(count, 1, 2);
+ try requireUnitless(units[0..count]);
+ const sy = if (count == 2) values[1] else values[0];
+ return makeParsedTransform(.scale, scaleMatrix(values[0], sy, 1), values, count, true);
}
-
if (Eql(u8, name, "scaleX")) {
- return scaleMatrix(nums[0], 1, 1);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_x, scaleMatrix(values[0], 1, 1), values, count, true);
}
-
if (Eql(u8, name, "scaleY")) {
- return scaleMatrix(1, nums[0], 1);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_y, scaleMatrix(1, values[0], 1), values, count, true);
}
-
if (Eql(u8, name, "scaleZ")) {
- is_2d.* = false;
- return scaleMatrix(1, 1, nums[0]);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_z, scaleMatrix(1, 1, values[0]), values, count, false);
}
-
if (Eql(u8, name, "scale3d")) {
- is_2d.* = false;
- return scaleMatrix(nums[0], nums[1], nums[2]);
- }
- if (Eql(u8, name, "rotate") or Eql(u8, name, "rotateZ")) {
- if (Eql(u8, name, "rotateZ")) is_2d.* = false;
- return rotateZMatrix(toRadians(nums[0], units[0]));
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 3, 3);
+ try requireUnitless(units[0..count]);
+ return makeParsedTransform(.scale_3d, scaleMatrix(values[0], values[1], values[2]), values, count, false);
}
- if (Eql(u8, name, "rotateX")) {
- is_2d.* = false;
- return rotateXMatrix(toRadians(nums[0], units[0]));
- }
-
- if (Eql(u8, name, "rotateY")) {
- is_2d.* = false;
- return rotateYMatrix(toRadians(nums[0], units[0]));
- }
-
- if (Eql(u8, name, "rotate3d")) {
- is_2d.* = false;
- if (count != 4) {
- return error.SyntaxError;
+ if (Eql(u8, name, "rotate")) {
+ try requireCount(count, 1, if (syntax == .svg) 3 else 1);
+ if (count != 1 and count != 3) return error.SyntaxError;
+ try requireAngle(units[0]);
+ if (syntax == .svg and units[0] != .none) return error.SyntaxError;
+ if (count == 3) try requireUnitless(units[1..3]);
+ var matrix = rotateZMatrix(toRadians(values[0], units[0]));
+ if (count == 3) {
+ matrix = multiplyMatrix(translationMatrix(values[1], values[2], 0), matrix);
+ matrix = multiplyMatrix(matrix, translationMatrix(-values[1], -values[2], 0));
}
- return axisAngleMatrix(nums[0], nums[1], nums[2], toRadians(nums[3], units[3]));
+ return makeParsedTransform(.rotate, matrix, values, count, true);
+ }
+ if (Eql(u8, name, "rotateX") or Eql(u8, name, "rotateY") or Eql(u8, name, "rotateZ")) {
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ try requireAngle(units[0]);
+ if (Eql(u8, name, "rotateX")) return makeParsedTransform(.rotate_x, rotateXMatrix(toRadians(values[0], units[0])), values, count, false);
+ if (Eql(u8, name, "rotateY")) return makeParsedTransform(.rotate_y, rotateYMatrix(toRadians(values[0], units[0])), values, count, false);
+ return makeParsedTransform(.rotate_z, rotateZMatrix(toRadians(values[0], units[0])), values, count, false);
+ }
+ if (Eql(u8, name, "rotate3d")) {
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 4, 4);
+ try requireUnitless(units[0..3]);
+ try requireAngle(units[3]);
+ return makeParsedTransform(.rotate_3d, axisAngleMatrix(values[0], values[1], values[2], toRadians(values[3], units[3])), values, count, false);
}
if (Eql(u8, name, "skew")) {
- const ax = toRadians(nums[0], units[0]);
- const ay = if (count > 1) toRadians(nums[1], units[1]) else 0;
- return skewMatrix(ax, ay);
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 2);
+ try requireAngle(units[0]);
+ if (count == 2) try requireAngle(units[1]);
+ const ay = if (count == 2) toRadians(values[1], units[1]) else 0;
+ return makeParsedTransform(.skew, skewMatrix(toRadians(values[0], units[0]), ay), values, count, true);
}
-
- if (Eql(u8, name, "skewX")) {
- return skewMatrix(toRadians(nums[0], units[0]), 0);
- }
-
- if (Eql(u8, name, "skewY")) {
- return skewMatrix(0, toRadians(nums[0], units[0]));
+ if (Eql(u8, name, "skewX") or Eql(u8, name, "skewY")) {
+ try requireCount(count, 1, 1);
+ try requireAngle(units[0]);
+ if (syntax == .svg and units[0] != .none) return error.SyntaxError;
+ if (Eql(u8, name, "skewX")) return makeParsedTransform(.skew_x, skewMatrix(toRadians(values[0], units[0]), 0), values, count, true);
+ return makeParsedTransform(.skew_y, skewMatrix(0, toRadians(values[0], units[0])), values, count, true);
}
if (Eql(u8, name, "perspective")) {
- is_2d.* = false;
- var out = identity();
- if (nums[0] != 0) out[11] = -1.0 / nums[0];
- return out;
+ if (syntax == .svg) return error.SyntaxError;
+ try requireCount(count, 1, 1);
+ var matrix = identity();
+ if (values[0] != 0) matrix[11] = -1.0 / values[0];
+ return makeParsedTransform(.perspective, matrix, values, count, false);
}
return error.SyntaxError;
}
+fn makeParsedTransform(kind: TransformKind, matrix: [16]f64, values: [16]f64, count: usize, is_2d: bool) ParsedTransform {
+ return .{ .kind = kind, .matrix = matrix, .values = values, .count = count, .is_2d = is_2d };
+}
+
+fn requireCount(count: usize, minimum: usize, maximum: usize) !void {
+ if (count < minimum or count > maximum) return error.SyntaxError;
+}
+
+fn requireUnitless(units: []const ParsedValue.Unit) !void {
+ for (units) |unit| if (unit != .none) return error.SyntaxError;
+}
+
+fn requireAngle(unit: ParsedValue.Unit) !void {
+ if (unit == .other) return error.SyntaxError;
+}
+
+fn parseArguments(arguments: []const u8, syntax: TransformSyntax, values: *[16]f64, units: *[16]ParsedValue.Unit) !usize {
+ var scanner = ArgumentScanner{ .input = arguments, .syntax = syntax };
+ var count: usize = 0;
+ while (try scanner.next()) |value| {
+ if (count == values.len) return error.SyntaxError;
+ values[count] = value.value;
+ units[count] = value.unit;
+ count += 1;
+ }
+ return count;
+}
+
pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
return switch (unit) {
.rad => value,
@@ -491,6 +623,7 @@ pub fn toRadians(value: f64, unit: ParsedValue.Unit) f64 {
.turn => value * std.math.tau,
// bare numbers in rotate()/skew() are interpreted as degrees
.deg, .none => value * std.math.pi / 180.0,
+ .other => value,
};
}
@@ -725,43 +858,80 @@ const ParsedValue = struct {
rad,
grad,
turn,
+ other,
};
+};
- // Parses a single CSS dimension token: a number with an optional unit suffix.
- // Length units are ignored (we don't resolve layout), so the numeric part is
- // taken verbatim; angle units are recorded so they can be normalised.
- fn parse(tok: []const u8) !ParsedValue {
- var end: usize = 0;
- while (end < tok.len) : (end += 1) {
- const c = tok[end];
- if ((c >= '0' and c <= '9') or c == '.' or c == '+' or c == '-' or c == 'e' or c == 'E') {
- // 'e'/'E' is ambiguous with exponents; only treat as exponent when
- // followed by a digit/sign.
- if ((c == 'e' or c == 'E') and end > 0) {
- if (end + 1 >= tok.len) break;
- const nx = tok[end + 1];
- if (!((nx >= '0' and nx <= '9') or nx == '+' or nx == '-')) break;
- }
- continue;
+const ArgumentScanner = struct {
+ input: []const u8,
+ syntax: TransformSyntax,
+ index: usize = 0,
+ first: bool = true,
+
+ fn next(self: *ArgumentScanner) !?ParsedValue {
+ var had_whitespace = false;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) {
+ had_whitespace = true;
+ self.index += 1;
+ }
+
+ if (!self.first and self.index < self.input.len and self.input[self.index] == ',') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isWhitespace(self.input[self.index])) self.index += 1;
+ if (self.index == self.input.len) return error.SyntaxError;
+ } else if (!self.first and self.index < self.input.len) {
+ // CSS separates arguments with a comma and nothing else. The SVG
+ // grammar also takes whitespace, or no separator at all when the
+ // next number carries its own sign.
+ if (self.syntax == .css) return error.SyntaxError;
+ if (!had_whitespace and self.input[self.index] != '+' and self.input[self.index] != '-') {
+ return error.SyntaxError;
}
- break;
}
- if (end == 0) {
- return error.SyntaxError;
- }
- const value = try std.fmt.parseFloat(f64, tok[0..end]);
- const suffix = tok[end..];
- var unit: Unit = .none;
- if (std.ascii.eqlIgnoreCase(suffix, "deg")) {
- unit = .deg;
- } else if (std.ascii.eqlIgnoreCase(suffix, "rad")) {
- unit = .rad;
- } else if (std.ascii.eqlIgnoreCase(suffix, "grad")) {
- unit = .grad;
- } else if (std.ascii.eqlIgnoreCase(suffix, "turn")) {
- unit = .turn;
+ if (self.index == self.input.len) return null;
+ const start = self.index;
+ if (self.input[self.index] == '+' or self.input[self.index] == '-') self.index += 1;
+
+ var digits: usize = 0;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
+ if (self.index < self.input.len and self.input[self.index] == '.') {
+ self.index += 1;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) : (self.index += 1) digits += 1;
}
+ if (digits == 0) return error.SyntaxError;
+
+ if (self.index < self.input.len and (self.input[self.index] == 'e' or self.input[self.index] == 'E')) {
+ self.index += 1;
+ if (self.index < self.input.len and (self.input[self.index] == '+' or self.input[self.index] == '-')) self.index += 1;
+ const exponent_start = self.index;
+ while (self.index < self.input.len and std.ascii.isDigit(self.input[self.index])) self.index += 1;
+ if (self.index == exponent_start) return error.SyntaxError;
+ }
+
+ const number_end = self.index;
+ while (self.index < self.input.len and
+ (std.ascii.isAlphabetic(self.input[self.index]) or self.input[self.index] == '%'))
+ {
+ self.index += 1;
+ }
+ const suffix = self.input[number_end..self.index];
+ const unit: ParsedValue.Unit = if (suffix.len == 0)
+ .none
+ else if (std.ascii.eqlIgnoreCase(suffix, "deg"))
+ .deg
+ else if (std.ascii.eqlIgnoreCase(suffix, "rad"))
+ .rad
+ else if (std.ascii.eqlIgnoreCase(suffix, "grad"))
+ .grad
+ else if (std.ascii.eqlIgnoreCase(suffix, "turn"))
+ .turn
+ else
+ .other;
+
+ const value = std.fmt.parseFloat(f64, self.input[start..number_end]) catch return error.SyntaxError;
+ if (!std.math.isFinite(value)) return error.SyntaxError;
+ self.first = false;
return .{ .value = value, .unit = unit };
}
};
diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig
index c85b497d2..3a8088bd1 100644
--- a/src/browser/webapi/DOMParser.zig
+++ b/src/browser/webapi/DOMParser.zig
@@ -24,7 +24,6 @@ const Frame = @import("../Frame.zig");
const Parser = @import("../parser/Parser.zig");
const HTMLDocument = @import("HTMLDocument.zig");
-const XMLDocument = @import("XMLDocument.zig");
const Document = @import("Document.zig");
const DOMParser = @This();
@@ -50,22 +49,22 @@ pub fn parseFromString(
@"image/svg+xml",
}, mime_type) orelse return error.NotSupported;
- const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
- defer frame.releaseArena(arena);
-
- // DOMParser builds a detached Document. Borrow the same fragment
- // parse-mode that `parseHtmlAsChildren` uses so frame-side hooks
- // triggered from `Build.created` / `nodeIsReady` (external stylesheet
- // fetches, script execution, mutation-observer fan-out, default-script
- // injection) treat the parsed nodes as detached and skip
- // side effects on the live document. The frame's `_parse_mode` is
- // restored on exit.
- const previous_parse_mode = frame._parse_mode;
- frame._parse_mode = .fragment;
- defer frame._parse_mode = previous_parse_mode;
-
return switch (target_mime) {
.@"text/html" => {
+ const arena = try frame.getArena(.medium, "DOMParser.parseFromString");
+ defer frame.releaseArena(arena);
+
+ // DOMParser builds a detached Document. Borrow the same fragment
+ // parse-mode that `Frame.parse` uses so frame-side hooks
+ // triggered from `Build.created` / `nodeIsReady` (external
+ // stylesheet fetches, script execution, mutation-observer fan-out,
+ // default-script injection) treat the parsed nodes as detached and
+ // skip side effects on the live document. The frame's
+ // `_parse_mode` is restored on exit.
+ const previous_parse_mode = frame._parse_mode;
+ frame._parse_mode = .fragment;
+ defer frame._parse_mode = previous_parse_mode;
+
// Create a new HTMLDocument
const doc = try frame._factory.document(HTMLDocument{
._proto = undefined,
@@ -79,6 +78,9 @@ pub fn parseFromString(
// Parse HTML into the document
var parser = Parser.init(arena, doc.asNode(), frame, .{});
parser.parse(normalized);
+ if (parser.terminated) {
+ return error.ExecutionTerminated;
+ }
if (parser.err) |pe| {
return pe.err;
@@ -87,32 +89,10 @@ pub fn parseFromString(
return doc.asDocument();
},
else => {
- // Create a new XMLDocument.
- const doc = try frame._factory.document(XMLDocument{
- ._proto = undefined,
- });
-
- // Parse XML into XMLDocument.
- const doc_node = doc.asNode();
- var parser = Parser.init(arena, doc_node, frame, .{});
- parser.parseXML(html);
-
- if (parser.err != null or doc_node.firstChild() == null) {
+ const doc = (try Frame.parse.xmlDocument(frame, html)) orelse blk: {
// Return a document with a element per spec.
- const err_doc = try frame._factory.document(XMLDocument{ ._proto = undefined });
- var err_parser = Parser.init(arena, err_doc.asNode(), frame, .{});
- err_parser.parseXML("error");
- return err_doc.asDocument();
- }
-
- const first_child = doc_node.firstChild().?;
-
- // If first node is a `ProcessingInstruction`, skip it.
- if (first_child.getNodeType() == 7) {
- // We're sure that firstChild exist, this cannot fail.
- _ = try doc_node.removeChild(first_child, frame);
- }
-
+ break :blk (try Frame.parse.xmlDocument(frame, "error")).?;
+ };
return doc.asDocument();
},
};
diff --git a/src/browser/webapi/DOMPoint.zig b/src/browser/webapi/DOMPoint.zig
index 0df456604..d9356d3f2 100644
--- a/src/browser/webapi/DOMPoint.zig
+++ b/src/browser/webapi/DOMPoint.zig
@@ -77,17 +77,17 @@ pub fn getW(self: *const DOMPoint) f64 {
return self._proto._w;
}
-pub fn setX(self: *DOMPoint, v: f64) void {
- self._proto._x = v;
+pub fn setX(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.x, v);
}
-pub fn setY(self: *DOMPoint, v: f64) void {
- self._proto._y = v;
+pub fn setY(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.y, v);
}
-pub fn setZ(self: *DOMPoint, v: f64) void {
- self._proto._z = v;
+pub fn setZ(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.z, v);
}
-pub fn setW(self: *DOMPoint, v: f64) void {
- self._proto._w = v;
+pub fn setW(self: *DOMPoint, v: f64) !void {
+ try self._proto.setCoordinate(.w, v);
}
pub const JsApi = struct {
diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig
index db91e5a3f..a0bf84a2f 100644
--- a/src/browser/webapi/DOMPointReadOnly.zig
+++ b/src/browser/webapi/DOMPointReadOnly.zig
@@ -38,6 +38,23 @@ _x: f64,
_y: f64,
_z: f64,
_w: f64,
+_attachment: ?Attachment = null,
+
+// Sticky. An animVal item that a later external attribute change detaches from
+// its list must not turn into a writable orphan.
+_read_only: bool = false,
+
+// SVGPoint's coordinates are a restricted float: NaN and infinity are a
+// TypeError rather than a stored value. Only points that reach an SVG list are
+// restricted; a plain DOMPoint takes an unrestricted double.
+_restricted: bool = false,
+
+pub const Coordinate = enum { x, y, z, w };
+
+pub const Attachment = struct {
+ owner: *anyopaque,
+ mutate: *const fn (*anyopaque, *DOMPointReadOnly, Coordinate, f64) anyerror!void,
+};
pub const Type = union(enum) {
generic,
@@ -132,6 +149,47 @@ pub fn getW(self: *const DOMPointReadOnly) f64 {
return self._w;
}
+pub fn setCoordinate(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) !void {
+ if (self._read_only) return error.NoModificationAllowed;
+ if (self._restricted and !std.math.isFinite(value)) return error.TypeError;
+ if (self._attachment) |attachment| {
+ return attachment.mutate(attachment.owner, self, coordinate, value);
+ }
+ self.setCoordinateRaw(coordinate, value);
+}
+
+pub fn setCoordinateRaw(self: *DOMPointReadOnly, coordinate: Coordinate, value: f64) void {
+ switch (coordinate) {
+ .x => self._x = value,
+ .y => self._y = value,
+ .z => self._z = value,
+ .w => self._w = value,
+ }
+}
+
+pub fn restrict(self: *DOMPointReadOnly) void {
+ self._restricted = true;
+}
+
+pub fn attach(self: *DOMPointReadOnly, attachment: Attachment, read_only: bool) void {
+ self._attachment = attachment;
+ if (read_only) self._read_only = true;
+}
+
+pub fn detach(self: *DOMPointReadOnly, owner: *anyopaque) void {
+ const attachment = self._attachment orelse return;
+ if (attachment.owner == owner) self._attachment = null;
+}
+
+pub fn isAttached(self: *const DOMPointReadOnly) bool {
+ return self._attachment != null;
+}
+
+pub fn isAttachedTo(self: *const DOMPointReadOnly, owner: *anyopaque) bool {
+ const attachment = self._attachment orelse return false;
+ return attachment.owner == owner;
+}
+
pub fn toJSON(self: *const DOMPointReadOnly) struct {
x: f64,
y: f64,
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
index 4e52cfe53..665b9402c 100644
--- a/src/browser/webapi/Element.zig
+++ b/src/browser/webapi/Element.zig
@@ -484,7 +484,7 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
var fragment: ?*Node = null;
if (html.len > 0) {
const frag = (try Node.DocumentFragment.init(frame)).asNode();
- try frame.parseHtmlAsChildren(frag, html);
+ try Frame.parse.htmlAsChildren(frame, frag, html);
fragment = frag;
}
@@ -856,6 +856,10 @@ pub fn removeAttribute(self: *Element, name: String, frame: *Frame) !void {
return self._attributes.delete(name, self, frame);
}
+pub fn removeAttributeSafe(self: *Element, name: String, frame: *Frame) void {
+ self._attributes.deleteSafe(name, self, frame);
+}
+
pub fn toggleAttribute(self: *Element, name: String, force: ?bool, frame: *Frame) !bool {
try Attribute.validateAttributeName(name);
const has = try self.hasAttribute(name, frame);
@@ -1377,13 +1381,106 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void {
}
pub fn getScrollHeight(self: *Element, frame: *Frame) f64 {
- // In our dummy layout engine, content doesn't overflow
- return self.getClientHeight(frame);
+ var visibility_cache: VisibilityCache = .{};
+ if (!self.checkVisibilityCached(&visibility_cache, frame)) {
+ return 0.0;
+ }
+
+ const height = self.getElementDimensions(frame).height;
+
+ const tag = self.getTag();
+ // As in getScrollWidth: the root containers carry artificial giant
+ // defaults, and page-level overflow checks read them.
+ if (tag == .html or tag == .body) {
+ return height;
+ }
+
+ return @max(height, self.contentHeight(frame, &visibility_cache));
+}
+
+// The height of the direct child elements stacked vertically, the counterpart
+// of contentWidth.
+//
+// Note that the two assume contradictory arrangements — contentWidth lays the
+// children out in a row, this stacks them. That is deliberate. We can't detect
+// the real layout mode (see contentWidth), so each axis independently assumes
+// the arrangement that produces overflow. Together they bound the content
+// extent per axis rather than describing one coherent layout: reporting no
+// overflow when there is some is what wedges measure-then-mutate loops,
+// while the reverse merely over-reports.
+fn contentHeight(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
+ var total: f64 = 0;
+
+ var child = self.asNode().firstChild();
+ while (child) |node| : (child = node.nextSibling()) {
+ if (node.is(Element)) |el| {
+ if (el.checkVisibilityCached(visibility_cache, frame)) {
+ total += el.getElementDimensions(frame).height;
+ }
+ }
+ }
+
+ return total;
}
pub fn getScrollWidth(self: *Element, frame: *Frame) f64 {
- // In our dummy layout engine, content doesn't overflow
- return self.getClientWidth(frame);
+ var visibility_cache: VisibilityCache = .{};
+ if (!self.checkVisibilityCached(&visibility_cache, frame)) {
+ return 0.0;
+ }
+
+ const width = self.getElementDimensions(frame).width;
+
+ const tag = self.getTag();
+ // The root containers carry artificial giant defaults (1920 and
+ // 100_000_000, see getElementDimensions). Stacking their children on
+ // top would inflate a value sites read to detect page overflow.
+ if (tag == .html or tag == .body) {
+ return width;
+ }
+
+ return @max(width, self.contentWidth(frame, &visibility_cache));
+}
+
+// The width of the direct child elements laid end to end on a single row.
+//
+// The dummy layout engine has no line-breaking, and an element only overflows
+// horizontally when its children don't wrap (white-space:nowrap, a flex row, an
+// inline-block strip), so the single-row assumption covers the case that
+// matters. We can't detect the layout mode to do better: getStyle() sees only
+// the inline `style=` attribute, and the computed cascade resolves stylesheet
+// rules for `display:none` and `visibility` alone.
+//
+// Only direct children are measured, never the whole subtree. This runs on
+// every scrollWidth read, and recursing would make an element's cost O(subtree)
+// rather than O(fan-out).
+//
+// Growing with the child count is the point: JS that appends content until
+// `scrollWidth` passes a threshold (the infinite-marquee idiom) never
+// terminates when the metric ignores what it just inserted.
+//
+// Text children are not measured, matching contentHeight. Estimating a text run
+// from its length would need a per-character advance, which in turn has to track
+// font-size or "shrink the font until it fits" loops stop converging — and it
+// would report overflow for practically every element containing text, since a
+// few words already exceed the default box. Element children are what content
+// grown by script actually consists of.
+fn contentWidth(self: *Element, frame: *Frame, visibility_cache: *VisibilityCache) f64 {
+ var total: f64 = 0;
+
+ // The cache arrives seeded by the caller's own visibility walk, and
+ // siblings share that ancestor chain, so the loop costs one own-element
+ // check per child rather than N ancestor walks.
+ var child = self.asNode().firstChild();
+ while (child) |node| : (child = node.nextSibling()) {
+ if (node.is(Element)) |el| {
+ if (el.checkVisibilityCached(visibility_cache, frame)) {
+ total += el.getElementDimensions(frame).width;
+ }
+ }
+ }
+
+ return total;
}
pub fn getOffsetHeight(self: *Element, frame: *Frame) f64 {
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
index ab92c573a..5dc4f6251 100644
--- a/src/browser/webapi/Event.zig
+++ b/src/browser/webapi/Event.zig
@@ -437,7 +437,7 @@ pub fn composedPath(self: *Event, exec: *Execution) ![]const *EventTarget {
const visible_path_len = if (path_len > visible_start_index) path_len - visible_start_index else 0;
// Allocate and return the visible path using call_arena (short-lived)
- const path = try exec.call_arena.alloc(*EventTarget, visible_path_len);
+ const path = try exec.local_arena.alloc(*EventTarget, visible_path_len);
@memcpy(path, path_buffer[visible_start_index..path_len]);
return path;
}
diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig
index be47ff982..08befb559 100644
--- a/src/browser/webapi/HTMLDocument.zig
+++ b/src/browser/webapi/HTMLDocument.zig
@@ -71,7 +71,7 @@ pub fn setBody(self: *HTMLDocument, html: []const u8, frame: *Frame) !void {
// parsing strips any // wrappers the author included.
const new_body_node = try Frame.node_factory.createElementNS(frame, .html, "body", null);
if (html.len > 0) {
- try frame.parseHtmlAsChildren(new_body_node, html);
+ try Frame.parse.htmlAsChildren(frame, new_body_node, html);
}
const document_node = document_element.asNode();
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
index 898e28f57..0bd37dbcc 100644
--- a/src/browser/webapi/Node.zig
+++ b/src/browser/webapi/Node.zig
@@ -1557,9 +1557,9 @@ pub fn setHTML(self: *Node, html: []const u8, allow_declarative_shadow: bool, fr
if (html.len > 0) {
if (allow_declarative_shadow) {
- try frame.parseHtmlUnsafeAsChildren(self, html);
+ try Frame.parse.htmlUnsafeAsChildren(frame, self, html);
} else {
- try frame.parseHtmlAsChildren(self, html);
+ try Frame.parse.htmlAsChildren(frame, self, html);
}
}
diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig
index cc443566b..9339fde76 100644
--- a/src/browser/webapi/Range.zig
+++ b/src/browser/webapi/Range.zig
@@ -703,7 +703,7 @@ pub fn createContextualFragment(self: *const Range, html: []const u8, frame: *Fr
else
try Frame.node_factory.createElementNS(frame, .html, "div", null);
- try frame.parseContextualFragment(temp_node, html);
+ try Frame.parse.contextualFragment(frame, temp_node, html);
// Move all parsed children to the fragment
// Keep removing first child until temp element is empty
diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig
index 0029ca2a0..6dac4a385 100644
--- a/src/browser/webapi/ResizeObserver.zig
+++ b/src/browser/webapi/ResizeObserver.zig
@@ -52,6 +52,7 @@ _observations: std.ArrayList(Observation) = .empty,
const Observation = struct {
target: *Element,
+ connected: bool = false,
last_width: f64 = 0,
last_height: f64 = 0,
};
@@ -123,18 +124,50 @@ pub fn disconnect(self: *ResizeObserver, frame: *Frame) void {
Frame.observers.unregisterResizeObserver(frame, self);
}
+// True if any observed element's connectedness changed since the last
+// delivery. Runs on every DOM change (via Frame.observers), so it must stay
+// pointer walks only — no style lookups.
+pub fn connectivityChanged(self: *const ResizeObserver) bool {
+ for (self._observations.items) |obs| {
+ if (obs.target.asNode().isConnected() != obs.connected) {
+ return true;
+ }
+ }
+ return false;
+}
+
+// True if any observed element is `ancestor` or one of its descendants. Walks
+// the same parentElement chain StyleManager.isHidden resolves visibility
+// through.
+pub fn observesWithin(self: *const ResizeObserver, ancestor: *Element) bool {
+ for (self._observations.items) |obs| {
+ var current: ?*Element = obs.target;
+ while (current) |el| : (current = el.parentElement()) {
+ if (el == ancestor) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
// Gather the observations whose size changed since the last delivery and, if
// any, invoke the callback.
pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void {
var entries: std.ArrayList(*ResizeObserverEntry) = .empty;
+ // Observed elements share ancestors, so one cache serves the whole pass.
+ var visibility_cache: Element.VisibilityCache = .empty;
for (self._observations.items) |*obs| {
const target = obs.target;
+ const connected = target.asNode().isConnected();
+ obs.connected = connected;
const width, const height = blk: {
- if (obs.target.asNode().isConnected() == false) {
+ if (!connected or !target.checkVisibilityCached(&visibility_cache, frame)) {
break :blk .{ 0, 0 };
}
- break :blk .{ target.getClientWidth(frame), target.getClientHeight(frame) };
+ const dims = target.getElementDimensions(frame);
+ break :blk .{ dims.width, dims.height };
};
if (width == obs.last_width and height == obs.last_height) {
diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig
index f9282718c..9836ac802 100644
--- a/src/browser/webapi/WorkerGlobalScope.zig
+++ b/src/browser/webapi/WorkerGlobalScope.zig
@@ -262,7 +262,9 @@ pub fn makeRequest(self: *WorkerGlobalScope, req: HttpClient.Request) !void {
// Two-phase variant; see HttpClient.newRequest for the ownership contract.
pub fn newRequest(self: *WorkerGlobalScope, req: HttpClient.Request) !*HttpClient.Transfer {
- return self._session.browser.http_client.newRequest(req, &self._http_owner);
+ var r = req;
+ r.document_frame_id = self._frame._frame_id;
+ return self._session.browser.http_client.newRequest(r, &self._http_owner);
}
pub fn getSelf(self: *WorkerGlobalScope) *WorkerGlobalScope {
@@ -330,11 +332,11 @@ pub fn setOnUnhandledRejection(self: *WorkerGlobalScope, setter: ?FunctionSetter
const base64 = @import("encoding/base64.zig");
pub fn btoa(_: *const WorkerGlobalScope, input: base64.BinInput, exec: *JS.Execution) ![]const u8 {
- return base64.encode(exec.call_arena, input);
+ return base64.encode(exec.local_arena, input);
}
pub fn atob(_: *const WorkerGlobalScope, input: base64.BinInput, exec: *JS.Execution) !JS.String.OneByte {
- const bytes = try base64.decode(exec.call_arena, input);
+ const bytes = try base64.decode(exec.local_arena, input);
return .{ .bytes = bytes };
}
@@ -399,6 +401,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) !
.url = resolved_url,
.method = .GET,
.frame_id = self._frame_id,
+ .document_frame_id = self._frame._frame_id,
.loader_id = self._loader_id,
.headers = headers,
.cookie_jar = &session.cookie_jar,
diff --git a/src/browser/webapi/WorkerLocation.zig b/src/browser/webapi/WorkerLocation.zig
index 6de8e9fd2..a8ecda128 100644
--- a/src/browser/webapi/WorkerLocation.zig
+++ b/src/browser/webapi/WorkerLocation.zig
@@ -54,7 +54,7 @@ pub fn getHash(self: *const WorkerLocation) []const u8 {
}
pub fn getOrigin(self: *const WorkerLocation, exec: *const js.Execution) ![]const u8 {
- return (try U.getOrigin(exec.call_arena, self._url)) orelse "null";
+ return (try U.getOrigin(exec.local_arena, self._url)) orelse "null";
}
pub fn toString(self: *const WorkerLocation) [:0]const u8 {
diff --git a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
index d7c51213d..7183d7733 100644
--- a/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
+++ b/src/browser/webapi/canvas/CanvasRenderingContext2D.zig
@@ -43,7 +43,7 @@ pub fn getCanvas(self: *const CanvasRenderingContext2D) *Canvas {
}
pub fn getFillStyle(self: *const CanvasRenderingContext2D, exec: *Execution) ![]const u8 {
- var w = std.Io.Writer.Allocating.init(exec.call_arena);
+ var w = std.Io.Writer.Allocating.init(exec.local_arena);
try self._fill_style.format(&w.writer);
return w.written();
}
diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig
index 4311623d2..57969c406 100644
--- a/src/browser/webapi/collections/HTMLCollection.zig
+++ b/src/browser/webapi/collections/HTMLCollection.zig
@@ -36,6 +36,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
+ select_options,
selected_options,
links,
anchors,
@@ -54,6 +55,7 @@ _data: union(Mode) {
child_elements: NodeLive(.child_elements),
child_tag: NodeLive(.child_tag),
cells: NodeLive(.cells),
+ select_options: NodeLive(.select_options),
selected_options: NodeLive(.selected_options),
links: NodeLive(.links),
anchors: NodeLive(.anchors),
@@ -111,6 +113,7 @@ pub fn iterator(self: *HTMLCollection, exec: *const Execution) !*Iterator {
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
.child_tag => |*impl| .{ .child_tag = impl._tw.clone() },
.cells => |*impl| .{ .cells = impl._tw.clone() },
+ .select_options => |*impl| .{ .select_options = impl._tw.clone() },
.selected_options => |*impl| .{ .selected_options = impl._tw.clone() },
.links => |*impl| .{ .links = impl._tw.clone() },
.anchors => |*impl| .{ .anchors = impl._tw.clone() },
@@ -132,7 +135,8 @@ pub const Iterator = GenericIterator(struct {
child_elements: TreeWalker.Children,
child_tag: TreeWalker.Children,
cells: TreeWalker.Children,
- selected_options: TreeWalker.Children,
+ select_options: TreeWalker.FullExcludeSelf,
+ selected_options: TreeWalker.FullExcludeSelf,
links: TreeWalker.FullExcludeSelf,
anchors: TreeWalker.FullExcludeSelf,
form: TreeWalker.FullExcludeSelf,
@@ -157,6 +161,7 @@ pub const Iterator = GenericIterator(struct {
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
.child_tag => |*impl| impl.nextTw(&self.tw.child_tag),
.cells => |*impl| impl.nextTw(&self.tw.cells),
+ .select_options => |*impl| impl.nextTw(&self.tw.select_options),
.selected_options => |*impl| impl.nextTw(&self.tw.selected_options),
.links => |*impl| impl.nextTw(&self.tw.links),
.anchors => |*impl| impl.nextTw(&self.tw.anchors),
diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig
index 221075eb6..77d5ac2eb 100644
--- a/src/browser/webapi/collections/node_live.zig
+++ b/src/browser/webapi/collections/node_live.zig
@@ -40,6 +40,7 @@ const Mode = enum {
child_elements,
child_tag,
cells,
+ select_options,
selected_options,
links,
anchors,
@@ -68,6 +69,7 @@ const Filters = union(Mode) {
child_elements,
child_tag: Element.Tag,
cells,
+ select_options,
selected_options,
links,
anchors,
@@ -100,7 +102,10 @@ pub fn NodeLive(comptime mode: Mode) type {
const Filter = Filters.TypeOf(mode);
const TW = switch (mode) {
.tag, .tag_name, .tag_name_ns, .class_name, .name, .all_elements, .links, .anchors, .form => TreeWalker.FullExcludeSelf,
- .child_elements, .child_tag, .cells, .selected_options => TreeWalker.Children,
+ .child_elements, .child_tag, .cells => TreeWalker.Children,
+ // A select's options can sit one level down, inside an