diff --git a/Makefile b/Makefile index 7208bb9d3..3bd9eb4ee 100644 --- a/Makefile +++ b/Makefile @@ -38,11 +38,18 @@ endif # Building V8 from source takes 10+ minutes. `make download-v8` fetches the # matching prebuilt archive from the zig-v8-fork releases instead. The versions # are read from the install action so they can't drift from CI. +# +# The cache path is keyed on ZIG_V8_TAG as well as the archive name: a +# zig-v8-fork release keeps the same asset filename across tags (the name +# encodes only the V8 version), so a tag bump that leaves V8_VERSION alone +# still ships different bytes. Keying the cache on the filename alone made +# download-v8's `test -f` guard skip that refresh and leave a stale archive +# in place, which then fails at link time on undefined v8__* symbols. V8_ACTION := .github/actions/install/action.yml V8_VERSION := $(shell awk -F\' '/^ v8:/{f=1} f&&/default:/{print $$2; exit}' $(V8_ACTION)) ZIG_V8_TAG := $(shell awk -F\' '/^ zig-v8:/{f=1} f&&/default:/{print $$2; exit}' $(V8_ACTION)) V8_ARCHIVE := libc_v8_$(V8_VERSION)_$(OS)_$(ARCH).a -V8_CACHE := .lp-cache/prebuilt-v8/$(V8_ARCHIVE) +V8_CACHE := .lp-cache/prebuilt-v8/$(ZIG_V8_TAG)/$(V8_ARCHIVE) # If the prebuilt archive is in place and the caller hasn't set ZIGFLAGS, point # the build at it rather than building V8 from source. diff --git a/build.zig.zon b/build.zig.zon index 2391b63f9..245f3e4e5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -36,8 +36,8 @@ .hash = "sqlite3-3.53.2-DMxLWuAOAAA_Px0arJOIOaP4AKEu5prbsQgPMA35W1zz", }, .zenai = .{ - .url = "git+https://github.com/lightpanda-io/zenai.git#00c793fdd2797ae5fc9700c189d8617f167ae0dc", - .hash = "zenai-0.0.0-iOY_VGOnBQDaWE_Gn8cwhh0XXE8RZ8ESE7QtcITF-P73", + .url = "git+https://github.com/lightpanda-io/zenai.git#cab4242b2c498d18e5637a97045f8a51a74e1c32", + .hash = "zenai-0.0.0-iOY_VBbNBQCo_e6zGGSUX5yyB2UYmx0dy3XuVpgO6h2n", }, .isocline = .{ .url = "git+https://github.com/arrufat/isocline?ref=lightpanda#832a9fe25f5f4458fcc47b5acc7c21db669c2f47", diff --git a/src/Notification.zig b/src/Notification.zig index 55dcc4c1b..74fd5af3f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -392,8 +392,20 @@ pub fn deinit(self: *Notification) void { } pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void { - var list = &@field(self.event_listeners, @tagName(event)); + const allocator = self.allocator; + const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver)); + if (gop.found_existing) { + for (gop.value_ptr.items) |existing| { + if (existing.event == event) { + lp.assert(@as(*const anyopaque, @ptrCast(func)) == existing.func, "different notification callbacks per receiver", .{ .event = event }); + return; + } + } + } else { + gop.value_ptr.* = .empty; + } + var list = &@field(self.event_listeners, @tagName(event)); var listener = try self.mem_pool.create(); errdefer self.mem_pool.destroy(listener); @@ -405,12 +417,6 @@ pub fn register(self: *Notification, comptime event: EventType, receiver: anytyp .func = @ptrCast(func), .struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child), }; - - const allocator = self.allocator; - const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver)); - if (gop.found_existing == false) { - gop.value_ptr.* = .empty; - } try gop.value_ptr.append(allocator, listener); // we don't add this until we've successfully added the entry to diff --git a/src/agent/Agent.zig b/src/agent/Agent.zig index c2481a919..fcd62388a 100644 --- a/src/agent/Agent.zig +++ b/src/agent/Agent.zig @@ -296,10 +296,11 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent const effort = settings.resolveEffort(opts, remembered, will_repl, if (resolved) |r| r.credentials.provider else null); const verbosity = settings.resolveVerbosity(opts, remembered); const stream_enabled = settings.resolveStream(remembered); + browser_tools.search_engine = settings.resolveSearchEngine(remembered); if (resolved) |r| { if (r.source == .picked) { - settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled }) catch {}; + settings.saveRemembered(.{ .provider = r.credentials.provider, .model = model, .effort = effort, .verbosity = verbosity, .stream = stream_enabled, .search_engine = browser_tools.search_engine }) catch {}; } // provider/model now live in the status bar; just space before the help std.debug.print("\n", .{}); @@ -353,7 +354,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent try self.startSession(); - self.ai_client = if (llm) |l| try zenai.provider.Client.init(allocator, lp.io, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; + self.ai_client = if (llm) |l| try zenai.provider.Client.init(lp.io, allocator, l, .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(l.provider), .environ = lp.environ() }) else null; errdefer if (self.ai_client) |c| c.deinit(allocator); if (self.ai_client) |c| c.setInterrupt(&self.http_interrupt); @@ -721,6 +722,7 @@ fn handleMeta(self: *Agent, arena: std.mem.Allocator, meta: *const SlashCommand. .load => self.handleLoad(rest), .model => self.handleModel(arena, rest), .provider => self.handleProvider(arena, rest), + .searchEngine => self.handleSearchEngine(rest), } return false; } @@ -760,6 +762,21 @@ fn handleStream(self: *Agent, rest: []const u8) void { self.reportSaved("stream", if (on) "on" else "off"); } +/// `/searchEngine`: bare prints the current engine; an argument sets and +/// persists it, warning when the chosen engine's API key is unset. +fn handleSearchEngine(self: *Agent, rest: []const u8) void { + self.setEnumOption("searchEngine", &browser_tools.search_engine, rest); + const selected = std.meta.stringToEnum(browser_tools.SearchEngine, rest) orelse return; + const env_var: [:0]const u8 = switch (selected) { + .tavily => "TAVILY_API_KEY", + .brave => "BRAVE_API_KEY", + .auto, .duckduckgo => return, + }; + if (std.c.getenv(env_var) == null) { + self.terminal.printWarning("{s} is not set; the search tool will fail until you export it", .{env_var}); + } +} + /// Print cumulative session token usage, broken down so the cache's effect is /// visible — the REPL otherwise never surfaces the `$usage` line `--task` /// prints. Reads `total_usage` (accumulated per turn by `processUserMessage`); @@ -870,7 +887,7 @@ fn reportSaved(self: *Agent, label: []const u8, value: []const u8) void { self.terminal.printInfo("{s}: {s}", .{ label, value }); return; } - if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled })) { + if (settings.saveRemembered(.{ .provider = provider, .model = self.model, .effort = self.effort, .verbosity = self.terminal.verbosity, .stream = self.stream_enabled, .search_engine = browser_tools.search_engine })) { self.terminal.printInfo("{s}: {s} (saved to {s})", .{ label, value, settings.remembered_path }); } else |_| { self.terminal.printInfo("{s}: {s}", .{ label, value }); @@ -968,7 +985,7 @@ fn hfBillTo(provider: Config.AiProvider) ?[]const u8 { /// `owned_key` transfers ownership of an allocated `credentials.key` (Vertex /// gcloud token) on success; on error the caller still owns it. fn setProvider(self: *Agent, credentials: Credentials, owned_key: ?[:0]const u8) !void { - const new_client = try zenai.provider.Client.init(self.allocator, lp.io, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); + const new_client = try zenai.provider.Client.init(lp.io, self.allocator, credentials, .{ .base_url = self.model_base_url, .retry_policy = .long_running, .bill_to = hfBillTo(credentials.provider), .environ = lp.environ() }); errdefer new_client.deinit(self.allocator); // A same-provider re-select (vertex token refresh) must not reset the model. @@ -1361,6 +1378,10 @@ fn printSlashHelp(self: *Agent, arena: std.mem.Allocator, target: []const u8) vo "/provider [name|null] — change the provider, or 'null' to disable the LLM (persisted, so the next launch starts in basic mode); Tab completes detected providers, bare /provider shows the current one", .{}, ), + .searchEngine => self.terminal.printInfo( + "/searchEngine " ++ Config.tagHint(browser_tools.SearchEngine) ++ " — set the web search engine behind the search tool (currently: {s}); saved to {s}. 'auto' tries Brave then Tavily (when their API keys are set) and falls back to the DuckDuckGo scrape; an explicit engine is used alone. Bare /searchEngine prints the engine.", + .{ @tagName(browser_tools.search_engine), settings.remembered_path }, + ), } return; } @@ -1845,7 +1866,7 @@ pub fn listModels(allocator: std.mem.Allocator, opts: Config.Agent) !void { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, opts.base_url, lp.environ()) catch |err| { + const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = opts.base_url, .environ = lp.environ() }) catch |err| { if (llm.provider == .vertex and !settings.vertexProjectMode()) { std.debug.print("Vertex express mode cannot list models (the endpoint requires OAuth); set GOOGLE_CLOUD_PROJECT for project mode.\n", .{}); } @@ -1902,13 +1923,12 @@ fn completionModels(context: *anyopaque, _: std.mem.Allocator) []const []const u _ = self.model_completion_arena.reset(.retain_capacity); const ids = zenai.provider.listChatModelIds( - self.allocator, lp.io, + self.allocator, self.model_completion_arena.allocator(), llm.provider, llm.key, - self.model_base_url, - lp.environ(), + .{ .base_url = self.model_base_url, .environ = lp.environ() }, ) catch &.{}; self.model_completions = .{ .provider = llm.provider, .ids = ids }; return ids; diff --git a/src/agent/SlashCommand.zig b/src/agent/SlashCommand.zig index 790f48fe7..e84d855db 100644 --- a/src/agent/SlashCommand.zig +++ b/src/agent/SlashCommand.zig @@ -17,7 +17,7 @@ // along with this program. If not, see . //! REPL-only meta slash commands (`/help`, `/quit`, `/verbosity`, `/effort`, -//! `/stream`, `/usage`, `/model`, `/provider`). Not tool slash commands — handled by +//! `/stream`, `/usage`, `/model`, `/provider`, `/searchEngine`). Not tool slash commands — handled by //! `Agent.handleMeta`, never reaching the recorder. Tool slash-command schema //! primitives live in `lp.Schema`; import that directly. @@ -46,7 +46,7 @@ pub const MetaCommand = struct { /// Dispatched by `Agent.handleMeta` via an exhaustive switch, so a new meta /// command is a compile error until it's wired up there too. - const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider }; + const Tag = enum { help, quit, verbosity, effort, stream, usage, clear, reset, save, load, model, provider, searchEngine }; }; const tagNames = Config.tagNames; @@ -65,6 +65,7 @@ pub const meta_commands = [_]MetaCommand{ .{ .tag = .load, .name = "load", .hint = "", .values = &.{}, .description = "Load and run a script from disk" }, .{ .tag = .model, .name = "model", .hint = "[name]", .values = &.{}, .description = "Change the model" }, .{ .tag = .provider, .name = "provider", .hint = "[name|null]", .values = &.{}, .description = "Change the provider, or 'null' to disable the LLM" }, + .{ .tag = .searchEngine, .name = "searchEngine", .hint = tagHint(browser_tools.SearchEngine), .values = tagNames(browser_tools.SearchEngine), .description = "Change the web search engine" }, }; /// Derived from `Command.LlmCommand` — name and description both come from the diff --git a/src/agent/settings.zig b/src/agent/settings.zig index f819758dd..d7d3bcae1 100644 --- a/src/agent/settings.zig +++ b/src/agent/settings.zig @@ -50,7 +50,7 @@ pub fn detectLocalProvider(allocator: std.mem.Allocator, tag: Config.AiProvider, const key = zenai.provider.envApiKey(lp.environ(), tag) orelse return null; var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), tag, key, base_url, lp.environ()) catch return null; + const ids = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), tag, key, .{ .base_url = base_url, .environ = lp.environ() }) catch return null; if (ids.len == 0) return null; return .{ .provider = tag, .key = key }; } @@ -198,6 +198,7 @@ pub const Remembered = struct { effort: ?Config.Effort = null, verbosity: ?Config.AgentVerbosity = null, stream: ?bool = null, + search_engine: ?lp.tools.SearchEngine = null, }; pub fn loadRemembered(allocator: std.mem.Allocator) ?Remembered { @@ -282,6 +283,13 @@ pub fn resolveStream(remembered: ?Remembered) bool { return true; } +/// Precedence: remembered `.lp-agent.zon` value > default (auto). No CLI +/// flag — the REPL `/searchEngine` command sets and persists it. +pub fn resolveSearchEngine(remembered: ?Remembered) lp.tools.SearchEngine { + if (remembered) |r| if (r.search_engine) |e| return e; + return .auto; +} + pub const ReconciledModel = union(enum) { /// Owned by the allocator passed to reconcileModel. use: []u8, @@ -302,7 +310,7 @@ pub fn reconcileModel( ) !ReconciledModel { var arena: std.heap.ArenaAllocator = .init(allocator); defer arena.deinit(); - const ids: []const []const u8 = zenai.provider.listChatModelIds(allocator, lp.io, arena.allocator(), llm.provider, llm.key, base_url, lp.environ()) catch &.{}; + const ids: []const []const u8 = zenai.provider.listChatModelIds(lp.io, allocator, arena.allocator(), llm.provider, llm.key, .{ .base_url = base_url, .environ = lp.environ() }) catch &.{}; if (ids.len == 0 or string.isOneOf(desired, ids)) return .{ .use = try allocator.dupe(u8, desired) }; if (!explicit) { @@ -352,6 +360,18 @@ test "parseRemembered: stream field round-trips" { try testing.expect(remembered.stream == false); } +test "parseRemembered: search_engine field round-trips" { + const remembered = parseRemembered(testing.allocator, ".{ .model = \"m\", .search_engine = .brave }").?; + defer std.zon.parse.free(testing.allocator, remembered); + try testing.expect(remembered.search_engine == .brave); +} + +test "resolveSearchEngine: default auto, remembered wins" { + try testing.expect(resolveSearchEngine(null) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = null }) == .auto); + try testing.expect(resolveSearchEngine(.{ .model = "m", .search_engine = .duckduckgo }) == .duckduckgo); +} + test "resolveStream: default on, remembered wins" { try testing.expect(resolveStream(null)); try testing.expect(resolveStream(.{ .model = "m", .stream = null })); diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 9c2dda898..a1072e995 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -42,9 +42,6 @@ const Event = @import("webapi/Event.zig"); const EventTarget = @import("webapi/EventTarget.zig"); const Element = @import("webapi/Element.zig"); const HtmlElement = @import("webapi/element/Html.zig"); -const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); -const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); -const AnimatedString = @import("webapi/svg/AnimatedString.zig"); const Window = @import("webapi/Window.zig"); const Location = @import("webapi/Location.zig"); const Document = @import("webapi/Document.zig"); @@ -65,14 +62,23 @@ const popover = @import("webapi/element/popover.zig"); const slotting = @import("webapi/element/slotting.zig"); const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; -const HttpClient = @import("../network/HttpClient.zig"); +const PointList = @import("webapi/svg/PointList.zig"); +const StringList = @import("webapi/svg/StringList.zig"); +const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); +const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig"); +const AnimatedString = @import("webapi/svg/AnimatedString.zig"); +const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig"); +const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); + const sys_url = @import("../sys/url.zig"); +const HttpClient = @import("../network/HttpClient.zig"); const timestamp = @import("../datetime.zig").timestamp; const milliTimestamp = @import("../datetime.zig").milliTimestamp; const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; +pub const parse = @import("frame/parse.zig"); pub const preload = @import("frame/preload.zig"); pub const observers = @import("frame/observers.zig"); pub const user_input = @import("frame/user_input.zig"); @@ -145,8 +151,12 @@ _node_owner_documents: Node.OwnerDocumentLookup = .empty, _element_scroll_positions: Element.ScrollPositionLookup = .empty, _element_namespace_uris: Element.NamespaceUriLookup = .empty, _svg_animated_lengths: AnimatedLength.Lookup = .empty, +_svg_animated_numbers: AnimatedNumber.Lookup = .empty, _svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty, _svg_animated_strings: AnimatedString.Lookup = .empty, +_svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty, +_svg_point_lists: PointList.Lookup = .empty, +_svg_string_lists: StringList.Lookup = .empty, // Same as above, but for Nodes (slot assigments apply to both Element AND // Text nodes) @@ -502,6 +512,16 @@ pub fn deinit(self: *Frame) void { observers.deinit(self, page); + var svg_point_lists = self._svg_point_lists.valueIterator(); + while (svg_point_lists.next()) |list| { + list.*.deinit(page); + } + + var svg_transform_lists = self._svg_animated_transform_lists.valueIterator(); + while (svg_transform_lists.next()) |list| { + list.*.deinit(page); + } + var document = self.window._document; document._selection.releaseRef(page); @@ -1024,7 +1044,9 @@ pub fn makeRequest(self: *Frame, req: HttpClient.Request) !void { // Two-phase variant; see HttpClient.newRequest for the ownership contract. pub fn newRequest(self: *Frame, 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_id; + return self._session.browser.http_client.newRequest(r, &self._http_owner); } // Synchronously abort every transfer and WebSocket owned by this frame @@ -1960,7 +1982,7 @@ pub fn domChanged(self: *Frame) void { // A DOM change is our "rendering opportunity": re-evaluate the layout // observers. Both are no-ops unless something they track actually changed. observers.scheduleIntersectionChecks(self); - observers.scheduleResizeDelivery(self); + observers.scheduleResizeChecks(self); } const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) }; @@ -2867,74 +2889,6 @@ pub fn updateRangesForNodeRemoval(self: *Frame, parent: *Node, child: *Node, chi } } -// TODO: optimize and cleanup, this is called a lot (e.g., innerHTML = '') -pub fn parseHtmlAsChildren(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(node, html, .{}); -} - -// setHTMLUnsafe variant: parse a fragment that may contain declarative shadow node -pub fn parseHtmlUnsafeAsChildren(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(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 parseContextualFragment(self: *Frame, node: *Node, html: []const u8) !void { - return self.parseHtmlAsChildrenInner(node, html, .{ .scripts_runnable = true }); -} - -const FragmentParseOpts = struct { - scripts_runnable: bool = false, - allow_declarative_shadow: bool = false, -}; - -fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: FragmentParseOpts) !void { - const previous_parse_mode = self._parse_mode; - self._parse_mode = .fragment; - defer self._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 (self._element_shadow_roots.count() != 0) { - const root = node.getRootNode(.{}); - if (root.is(ShadowRoot) != null) { - slotting.assignSlottablesForTree(root, self); - } - if (node.is(Element)) |el| { - if (self._element_shadow_roots.get(el)) |shadow_root| { - slotting.assignSlottablesForTree(shadow_root.asNode(), self); - } - } - }; - - const previous_scripts_runnable = self._fragment_scripts_runnable; - self._fragment_scripts_runnable = opts.scripts_runnable; - defer self._fragment_scripts_runnable = previous_scripts_runnable; - - var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow }); - parser.parseFragment(html); - - // 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; - } -} - // Runs the "ready" work for an inserted node and, when it's an element with // children, for its descendants in tree order: appending a subtree // containing scripts must execute them all, after the whole insertion. @@ -3379,9 +3333,6 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For form_data.acquireRef(); defer form_data.releaseRef(self._page); - const arena = try self._session.getArena(.medium, "submitForm"); - errdefer self._session.releaseArena(arena); - // Per HTML spec form-submission algorithm, when the submitter is a submit // button, its formaction/formmethod/formenctype attributes override the // form's corresponding attributes (matching how formtarget is honored above). @@ -3399,8 +3350,37 @@ pub fn submitForm(self: *Frame, submitter_: ?*Element, form_: ?*Element.Html.For break :blk form_element.getAttributeSafe(comptime .wrap("method")); }; const method = Element.Html.Form.normalizeMethod(method_attr, "get"); + + // Per the HTML form-submission algorithm, the dialog method closes the + // form's nearest ancestor dialog with the submitter's value and performs no + // navigation. Falling through here would submit the form as a GET, which + // both reloads the page and leaves the dialog open forever. + // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#form-submission-algorithm + if (std.mem.eql(u8, method, "dialog")) { + // A submit button always has a value (empty when unset); with no + // submitter at all the dialog's existing returnValue is left untouched. + const result: ?[]const u8 = if (submit_button) |s| blk: { + if (s.is(Element.Html.Form.Input)) |input| { + break :blk input.getValue(); + } + // if not an Input, it has to be a Button (checked above by isSubmitButton) + break :blk s.as(Element.Html.Form.Button).getValue(); + } else null; + + var ancestor: ?*Element = form_element; + while (ancestor) |el| : (ancestor = el.parentElement()) { + const dialog = el.is(Element.Html.Dialog) orelse continue; + try dialog.close(result, self); + break; + } + return; + } + const is_post = std.mem.eql(u8, method, "post"); + const arena = try self._session.getArena(.medium, "submitForm"); + errdefer self._session.releaseArena(arena); + // Get charset from accept-charset attribute or fall back to document charset const charset: []const u8 = blk: { if (form_element.getAttributeSafe(.wrap("accept-charset"))) |ac| { diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig index 8145f4979..72268c7fb 100644 --- a/src/browser/Mime.zig +++ b/src/browser/Mime.zig @@ -49,6 +49,7 @@ pub const ContentTypeEnum = enum { application_json, unknown, other, + other_xml, }; pub const ContentType = union(ContentTypeEnum) { @@ -68,6 +69,7 @@ pub const ContentType = union(ContentTypeEnum) { // A valid but unrecognized type/subtype. Keeping it would require some // memory management of the input. Nothing needs it right now, so why bother. other: void, + other_xml: void, // i.e. application/xml or */*+xml }; pub fn contentTypeString(mime: *const Mime) []const u8 { @@ -346,6 +348,13 @@ pub fn isHTML(self: *const Mime) bool { return self.content_type == .text_html; } +pub fn isXML(self: *const Mime) bool { + return switch (self.content_type) { + .text_xml, .other_xml => true, + else => false, + }; +} + pub fn isText(mime: *const Mime) bool { return switch (mime.content_type) { .text_xml, .text_html, .text_javascript, .text_plain, .text_css, .text_markdown => true, @@ -378,9 +387,11 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { @"image/webp", @"application/json", + @"application/xml", }, type_name)) |known_type| { const ct: ContentType = switch (known_type) { .@"text/xml" => .{ .text_xml = {} }, + .@"application/xml" => .{ .other_xml = {} }, .@"text/html" => .{ .text_html = {} }, .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} }, .@"text/plain" => .{ .text_plain = {} }, @@ -408,6 +419,10 @@ fn parseContentType(value: []const u8) !struct { ContentType, usize } { return error.Invalid; } + if (std.mem.endsWith(u8, type_name, "+xml")) { + return .{ .{ .other_xml = {} }, attribute_start }; + } + return .{ .{ .other = {} }, attribute_start }; } @@ -897,6 +912,28 @@ test "Mime: isHTML" { try assert(false, "over/9000"); } +test "Mime: isXML" { + defer testing.reset(); + + const assert = struct { + fn assert(expected: bool, input: []const u8) !void { + const mutable_input = try testing.arena_allocator.dupe(u8, input); + var mime = try Mime.parse(mutable_input); + try testing.expectEqual(expected, mime.isXML()); + } + }.assert; + try assert(true, "text/xml"); + try assert(true, "TEXT/XML; charset=utf-8"); + try assert(true, "application/xml"); + try assert(true, "image/svg+xml"); + try assert(true, "application/xhtml+xml"); + try assert(true, "application/rss+xml;charset=utf-8"); + try assert(false, "text/html"); + try assert(false, "application/json"); + try assert(false, "application/xmlfoo"); + try assert(false, "text/xm"); +} + test "Mime: sniff" { try testing.expectEqual(null, Mime.sniff("")); try testing.expectEqual(null, Mime.sniff(". -// Per-frame MutationObserver and IntersectionObserver bookkeeping: registration, -// the scheduling of the microtask deliveries, and broadcasting DOM mutations to -// the registered observers. The state lives on the Frame (frame._mutation / -// frame._intersection); these functions operate on it. +// Per-frame MutationObserver, IntersectionObserver and ResizeObserver +// bookkeeping: registration, the scheduling of the microtask deliveries, and +// broadcasting DOM mutations to the registered observers. The state lives on +// the Frame (frame._mutation / frame._intersection / frame._resize); these +// functions operate on it. const std = @import("std"); const lp = @import("lightpanda"); @@ -57,6 +58,7 @@ pub const Intersection = struct { pub const Resize = struct { // List of active ResizeObservers (i.e. those with >= 1 observation) observers: std.ArrayList(*ResizeObserver) = .empty, + check_scheduled: bool = false, delivery_scheduled: bool = false, delivery_depth: u32 = 0, }; @@ -171,6 +173,81 @@ pub fn scheduleResizeDelivery(frame: *Frame) void { }; } +// Called on every DOM change, a full delivery is too expensive to call here. +// What we can do is schedule a check. +pub fn scheduleResizeChecks(frame: *Frame) void { + if (frame._resize.observers.items.len == 0) { + return; + } + if (frame._resize.check_scheduled or frame._resize.delivery_scheduled) { + // check is already scheduled OR delivery is already scheduled + return; + } + frame._resize.check_scheduled = true; + frame.js.queueResizeChecks() catch |err| { + frame._resize.check_scheduled = false; + log.err(.frame, "frame.scheduleResizeChecks", .{ .err = err, .type = frame._type, .url = frame.url }); + }; +} + +pub fn performScheduledResizeChecks(frame: *Frame) void { + if (!frame._resize.check_scheduled) { + return; + } + + frame._resize.check_scheduled = false; + if (frame._resize.delivery_scheduled) { + return; + } + + // Check if we should schedule a delivery. If you're wondering why we're + // scheduling within a schedule, it's because this can be expensive and we + // want to coalesce as many of these into a single call as possible. + for (frame._resize.observers.items) |observer| { + if (observer.connectivityChanged()) { + scheduleResizeDelivery(frame); + return; + } + } +} + +// Only these attributes can change an element's size or visibility in our +// styling model (StyleManager.isHidden + Element.getElementDimensions), and +// only for the element itself and its descendants — so a delivery is only +// scheduled when an observed element is in the changed element's subtree. +fn resizeAttributeChanged(frame: *Frame, element: *Element, name: String) void { + if (frame._resize.observers.items.len == 0) { + return; + } + if (frame._resize.delivery_scheduled) { + return; + } + + // This has proven to be on the hot path + switch (name.len) { + 2 => if (!name.eqlWithSameLen(comptime .wrap("id"))) { + return; + }, + 4 => if (!name.eqlWithSameLen(comptime .wrap("type")) and !name.eqlWithSameLen(comptime .wrap("open"))) { + return; + }, + 5 => if (!name.eqlWithSameLen(comptime .wrap("style")) and !name.eqlWithSameLen(comptime .wrap("class")) and !name.eqlWithSameLen(comptime .wrap("width"))) { + return; + }, + 6 => if (!name.eqlWithSameLen(comptime .wrap("hidden")) and !name.eqlWithSameLen(comptime .wrap("height"))) { + return; + }, + else => return, + } + + for (frame._resize.observers.items) |observer| { + if (observer.observesWithin(element)) { + scheduleResizeDelivery(frame); + return; + } + } +} + pub fn deliverResizes(frame: *Frame) void { if (!frame._resize.delivery_scheduled) { return; @@ -183,7 +260,7 @@ pub fn deliverResizes(frame: *Frame) void { defer if (!frame._resize.delivery_scheduled) { frame._resize.delivery_depth = 0; }; - if (frame._resize.delivery_depth > 50) { + if (frame._resize.delivery_depth > 16) { log.warn(.frame, "frame.ResizeLimit", .{ .type = frame._type, .url = frame.url }); frame._resize.delivery_depth = 0; return; @@ -242,7 +319,7 @@ pub fn deliverMutations(frame: *Frame) void { frame._mutation.delivery_depth = 0; }; - if (frame._mutation.delivery_depth > 50) { + if (frame._mutation.delivery_depth > 16) { log.err(.frame, "frame.MutationLimit", .{ .type = frame._type, .url = frame.url }); frame._mutation.delivery_depth = 0; return; @@ -292,9 +369,10 @@ pub fn deliverMutations(frame: *Frame) void { } } -// Broadcast an attribute change to every registered MutationObserver. The -// caller (Frame.attributeChange / attributeRemove) handles the non-observer -// side effects (build hooks, custom-element callbacks, slot/popover updates). +// Broadcast an attribute change to every registered MutationObserver and to +// the resize bookkeeping. The caller (Frame.attributeChange / attributeRemove) +// handles the non-observer side effects (build hooks, custom-element +// callbacks, slot/popover updates). pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old_value: ?String) void { var it: ?*std.DoublyLinkedList.Node = frame._mutation.observers.first; while (it) |node| : (it = node.next) { @@ -303,6 +381,7 @@ pub fn notifyAttributeChange(frame: *Frame, element: *Element, name: String, old log.err(.frame, "attributeChange.notifyObserver", .{ .err = err, .type = frame._type, .url = frame.url }); }; } + resizeAttributeChanged(frame, element, name); } pub fn notifyCharacterDataChange(frame: *Frame, target: *Node, old_value: String) void { diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig new file mode 100644 index 000000000..526fc872d --- /dev/null +++ b/src/browser/frame/parse.zig @@ -0,0 +1,129 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const 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 \\Img \\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 , so + // these two walk the subtree and filter on the parent instead. + .select_options, .selected_options => TreeWalker.FullExcludeSelf, }; return struct { _tw: TW, @@ -299,11 +304,26 @@ pub fn NodeLive(comptime mode: Mode) type { const el = node.is(Element) orelse return false; return el.is(Element.Html.TableCell) != null; }, - .selected_options => { - const el = node.is(Element) orelse return false; - const Option = Element.Html.Option; - const opt = el.is(Option) orelse return false; - return opt.getSelected(); + .select_options, .selected_options => { + const opt = node.is(Element.Html.Option) orelse return false; + + // we have an option, but it's only a match IF + // 1 - this is a direct child of the root (i.e. the ) + + const parent = node.parentNode() orelse return false; + if (parent == self._tw._root) { + // case 1: it _is_ a direct child of the root + } else { + if (parent.is(Element.Html.OptGroup) == null or parent.parentNode() != self._tw._root) { + return false; + } + // the parent is an optgroup and its parent is the root + } + + return if (comptime mode == .selected_options) opt.getSelected() else true; }, .links => { // Links are elements with href attribute (TODO: also when implemented) @@ -390,6 +410,7 @@ pub fn NodeLive(comptime mode: Mode) type { .child_elements => HTMLCollection{ ._data = .{ .child_elements = self } }, .child_tag => HTMLCollection{ ._data = .{ .child_tag = self } }, .cells => HTMLCollection{ ._data = .{ .cells = self } }, + .select_options => HTMLCollection{ ._data = .{ .select_options = self } }, .selected_options => HTMLCollection{ ._data = .{ .selected_options = self } }, .links => HTMLCollection{ ._data = .{ .links = self } }, .anchors => HTMLCollection{ ._data = .{ .anchors = self } }, diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index 983c6aa5f..c2023a1ef 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -315,9 +315,17 @@ pub const List = struct { pub fn delete(self: *List, name: String, element: *Element, frame: *Frame) !void { const result = try self.getEntryAndNormalizedName(name, frame); const entry = result.entry orelse return; + return self._delete(entry, result.normalized, element, frame); + } + pub fn deleteSafe(self: *List, name: String, element: *Element, frame: *Frame) void { + const entry = self.getEntryWithNormalizedName(name) orelse return; + self._delete(entry, name, element, frame); + } + + fn _delete(self: *List, entry: *Entry, normalized: String, element: *Element, frame: *Frame) void { const owner = element.ownerFrame(frame); - const is_id = shouldAddToIdMap(result.normalized, element); + const is_id = shouldAddToIdMap(normalized, element); const old_value = entry.value(); if (is_id) { @@ -333,7 +341,7 @@ pub const List = struct { self._len -= 1; owner.domChanged(); - owner.attributeRemove(element, result.normalized, .wrap(old_value)); + owner.attributeRemove(element, normalized, .wrap(old_value)); } pub fn getNames(self: *const List, allocator: Allocator) ![][]const u8 { diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index c2776a1f0..9e190f465 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -290,7 +290,7 @@ pub fn insertAdjacentHTML( ) !void { const DocumentFragment = @import("../DocumentFragment.zig"); const fragment = (try DocumentFragment.init(frame)).asNode(); - try frame.parseHtmlAsChildren(fragment, html); + try Frame.parse.htmlAsChildren(frame, fragment, html); const target_node, const prev_node = try self.asNode().findAdjacentNodes(position, .html); @@ -1726,7 +1726,7 @@ pub const JsApi = struct { pub const innerText = bridge.accessor(_innerText, _setInnerText, .{ .ce_reactions = true }); fn _innerText(self: *HtmlElement, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getInnerText(&buf.writer, frame); return buf.written(); } diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 8d36299d5..95cbf9b04 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -216,7 +216,7 @@ pub fn setName(self: *Anchor, value: []const u8, frame: *Frame) !void { } pub fn getText(self: *Anchor, frame: *Frame) ![:0]const u8 { - return self.asNode().getTextContentAlloc(frame.call_arena); + return self.asNode().getTextContentAlloc(frame.local_arena); } pub fn setText(self: *Anchor, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Option.zig b/src/browser/webapi/element/html/Option.zig index 3a5b0d670..e677a44b0 100644 --- a/src/browser/webapi/element/html/Option.zig +++ b/src/browser/webapi/element/html/Option.zig @@ -53,7 +53,7 @@ pub fn getValue(self: *Option, frame: *Frame) []const u8 { } const node = self.asNode(); - const text = node.getTextContentAlloc(frame.call_arena) catch return ""; + const text = node.getTextContentAlloc(frame.local_arena) catch return ""; return std.mem.trim(u8, text, &std.ascii.whitespace); } diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index 88c99db6c..72ca44a38 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -145,13 +145,13 @@ pub const JsApi = struct { pub const supports = bridge.function(Script.supports, .{ .static = true }); pub const innerText = bridge.accessor(_innerText, Script.setInnerText, .{ .ce_reactions = true }); fn _innerText(self: *Script, frame: *const Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.asNode().getTextContent(&buf.writer); return buf.written(); } pub const text = bridge.accessor(_text, Script.setInnerText, .{ .ce_reactions = true }); fn _text(self: *Script, frame: *const Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.asNode().getChildTextContent(&buf.writer); return buf.written(); } diff --git a/src/browser/webapi/element/html/Select.zig b/src/browser/webapi/element/html/Select.zig index 3c3a04a3d..efe8cffa9 100644 --- a/src/browser/webapi/element/html/Select.zig +++ b/src/browser/webapi/element/html/Select.zig @@ -48,6 +48,45 @@ pub fn asConstNode(self: *const Select) *const Node { return self.asConstElement().asConstNode(); } +// Walks the select's list of options in tree order: its option children plus +// the option children of its optgroup children, per HTML +// §the-select-element. Options nested any deeper are not in the list. +const OptionIterator = struct { + _node: ?*Node, + _in_group: bool = false, + + fn init(select: *const Select) OptionIterator { + return .{ ._node = select.asConstNode().firstChild() }; + } + + fn next(self: *OptionIterator) ?*Option { + while (self._node) |node| { + if (self._in_group == false and node.is(Element.Html.OptGroup) != null) { + if (node.firstChild()) |first_child| { + self._in_group = true; + self._node = first_child; + continue; + } + } + + self._node = node.nextSibling() orelse blk: { + if (self._in_group == false) { + break :blk null; + } + // Exhausted the optgroup, resume after it. + self._in_group = false; + const group = node.parentNode() orelse break :blk null; + break :blk group.nextSibling(); + }; + + if (node.is(Option)) |option| { + return option; + } + } + return null; + } +}; + // Resolves the option whose selectedness contributes to the select's value // per HTML §form-elements§selectedness-setting-algorithm: an explicitly // selected non-disabled option, falling back to the first non-disabled @@ -56,11 +95,17 @@ pub fn asConstNode(self: *const Select) *const Node { // and contributes no entry to a FormData set. pub fn effectiveOption(self: *const Select) ?*Option { var first_option: ?*Option = null; - var maybe_child = self.asConstNode().firstChild(); - while (maybe_child) |child| : (maybe_child = child.nextSibling()) { - const option = child.is(Option) orelse continue; - if (option.getDisabled()) continue; - if (option.getSelected()) return option; + var it = OptionIterator.init(self); + while (it.next()) |option| { + // Element.isDisabled, not Option.getDisabled: an option is also + // disabled when its parent is an + // (HTML "concept-option-disabled"). + if (option.asElement().isDisabled()) { + continue; + } + if (option.getSelected()) { + return option; + } if (first_option == null) first_option = option; } return first_option; @@ -77,9 +122,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void { // Find option with matching value and select it // Note: This updates the current state (_selected), not the default state (attribute) // Setting value always deselects all others, even for multiple selects - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { option._selected = std.mem.eql(u8, option.getValue(frame), value); } } @@ -87,9 +131,8 @@ pub fn setValue(self: *Select, value: []const u8, frame: *Frame) !void { pub fn getSelectedIndex(self: *Select) i32 { var index: i32 = 0; var has_options = false; - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { has_options = true; if (option.getSelected()) { return index; @@ -112,9 +155,8 @@ pub fn setSelectedIndex(self: *Select, index: i32) !void { // Note: This updates the current state (_selected), not the default state (attribute) const is_multiple = self.getMultiple(); var current_index: i32 = 0; - var iter = self.asNode().childrenIterator(); - while (iter.next()) |child| { - const option = child.is(Option) orelse continue; + var it = OptionIterator.init(self); + while (it.next()) |option| { if (current_index == index) { option._selected = true; } else if (!is_multiple) { @@ -200,8 +242,9 @@ pub fn setRequired(self: *Select, required: bool, frame: *Frame) !void { } pub fn getOptions(self: *Select, frame: *Frame) !*collections.HTMLOptionsCollection { - // For options, we use the child_tag mode to filter only