From ecec932a477e7c8e7683de3d203e7bd8ad365fbd Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 17:13:12 +0800 Subject: [PATCH 01/10] Add setters to URL.username and URL.password Also, preserve port when setting host. --- src/browser/URL.zig | 103 ++++++++++++++++++++++++++++++------ src/browser/tests/url.html | 104 ++++++++++++++++++++++++++++++++++++- src/browser/webapi/URL.zig | 14 ++++- 3 files changed, 201 insertions(+), 20 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index 78d14b23..19b87333 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -167,17 +167,17 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { const query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end; const path_to_encode = url[path_start..path_end]; - const encoded_path = try percentEncodeSegment(allocator, path_to_encode, true); + const encoded_path = try percentEncodeSegment(allocator, path_to_encode, .path); const encoded_query = if (query_start) |qs| blk: { const query_to_encode = url[qs + 1 .. query_end]; - const encoded = try percentEncodeSegment(allocator, query_to_encode, false); + const encoded = try percentEncodeSegment(allocator, query_to_encode, .query); break :blk encoded; } else null; const encoded_fragment = if (fragment_start) |fs| blk: { const fragment_to_encode = url[fs + 1 ..]; - const encoded = try percentEncodeSegment(allocator, fragment_to_encode, false); + const encoded = try percentEncodeSegment(allocator, fragment_to_encode, .query); break :blk encoded; } else null; @@ -204,11 +204,13 @@ pub fn ensureEncoded(allocator: Allocator, url: [:0]const u8) ![:0]const u8 { return buf.items[0 .. buf.items.len - 1 :0]; } -fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_path: bool) ![]const u8 { +const EncodeSet = enum { path, query, userinfo }; + +fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime encode_set: EncodeSet) ![]const u8 { // Check if encoding is needed var needs_encoding = false; for (segment) |c| { - if (shouldPercentEncode(c, is_path)) { + if (shouldPercentEncode(c, encode_set)) { needs_encoding = true; break; } @@ -235,7 +237,7 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p } } - if (shouldPercentEncode(c, is_path)) { + if (shouldPercentEncode(c, encode_set)) { try buf.writer(allocator).print("%{X:0>2}", .{c}); } else { try buf.append(allocator, c); @@ -245,16 +247,17 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p return buf.items; } -fn shouldPercentEncode(c: u8, comptime is_path: bool) bool { +fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool { return switch (c) { // Unreserved characters (RFC 3986) 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false, - // sub-delims allowed in both path and query - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false, - // Separators allowed in both path and query - '/', ':', '@' => false, - // Query-specific: '?' is allowed in queries but not in paths - '?' => comptime is_path, + // sub-delims allowed in path/query but some must be encoded in userinfo + '!', '$', '&', '\'', '(', ')', '*', '+', ',' => false, + ';', '=' => encode_set == .userinfo, + // Separators: userinfo must encode these + '/', ':', '@' => encode_set == .userinfo, + // '?' is allowed in queries but not in paths or userinfo + '?' => encode_set != .query, // Everything else needs encoding (including space) else => true, }; @@ -514,7 +517,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) ! const search = getSearch(current); const hash = getHash(current); - // Check if the host includes a port + // Check if the new value includes a port const colon_pos = std.mem.lastIndexOfScalar(u8, value, ':'); const clean_host = if (colon_pos) |pos| blk: { const port_str = value[pos + 1 ..]; @@ -526,7 +529,14 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) ! break :blk value[0..pos]; } break :blk value; - } else value; + } else blk: { + // No port in new value - preserve existing port + const current_port = getPort(current); + if (current_port.len > 0) { + break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ value, current_port }); + } + break :blk value; + }; return buildUrl(allocator, protocol, clean_host, pathname, search, hash); } @@ -544,6 +554,9 @@ pub fn setHostname(current: [:0]const u8, value: []const u8, allocator: Allocato pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 { const hostname = getHostname(current); const protocol = getProtocol(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); // Handle null or default ports const new_host = if (value) |port_str| blk: { @@ -560,7 +573,7 @@ pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str }); } else hostname; - return setHost(current, new_host, allocator); + return buildUrl(allocator, protocol, new_host, pathname, search, hash); } pub fn setPathname(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { @@ -608,6 +621,64 @@ pub fn setHash(current: [:0]const u8, value: []const u8, allocator: Allocator) ! return buildUrl(allocator, protocol, host, pathname, search, hash); } +pub fn setUsername(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + const password = getPassword(current); + + const encoded_username = try percentEncodeSegment(allocator, value, .userinfo); + return buildUrlWithUserInfo(allocator, protocol, encoded_username, password, host, pathname, search, hash); +} + +pub fn setPassword(current: [:0]const u8, value: []const u8, allocator: Allocator) ![:0]const u8 { + const protocol = getProtocol(current); + const host = getHost(current); + const pathname = getPathname(current); + const search = getSearch(current); + const hash = getHash(current); + const username = getUsername(current); + + const encoded_password = try percentEncodeSegment(allocator, value, .userinfo); + return buildUrlWithUserInfo(allocator, protocol, username, encoded_password, host, pathname, search, hash); +} + +fn buildUrlWithUserInfo( + allocator: Allocator, + protocol: []const u8, + username: []const u8, + password: []const u8, + host: []const u8, + pathname: []const u8, + search: []const u8, + hash: []const u8, +) ![:0]const u8 { + if (username.len == 0 and password.len == 0) { + return buildUrl(allocator, protocol, host, pathname, search, hash); + } else if (password.len == 0) { + return std.fmt.allocPrintSentinel(allocator, "{s}//{s}@{s}{s}{s}{s}", .{ + protocol, + username, + host, + pathname, + search, + hash, + }, 0); + } else { + return std.fmt.allocPrintSentinel(allocator, "{s}//{s}:{s}@{s}{s}{s}{s}", .{ + protocol, + username, + password, + host, + pathname, + search, + hash, + }, 0); + } +} + pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 { if (query_string.len == 0) { return arena.dupeZ(u8, url); diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html index c5a71a85..f8074422 100644 --- a/src/browser/tests/url.html +++ b/src/browser/tests/url.html @@ -218,6 +218,106 @@ testing.expectEqual('', url.password); } + { + const url = new URL('https://example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('https://newuser@example.com/path', url.href); + } + + { + const url = new URL('https://olduser@example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('https://newuser@example.com/path', url.href); + } + + { + const url = new URL('https://olduser:pass@example.com/path'); + url.username = 'newuser'; + testing.expectEqual('newuser', url.username); + testing.expectEqual('pass', url.password); + testing.expectEqual('https://newuser:pass@example.com/path', url.href); + } + + { + const url = new URL('https://user@example.com/path'); + url.password = 'secret'; + testing.expectEqual('user', url.username); + testing.expectEqual('secret', url.password); + testing.expectEqual('https://user:secret@example.com/path', url.href); + } + + { + const url = new URL('https://user:oldpass@example.com/path'); + url.password = 'newpass'; + testing.expectEqual('user', url.username); + testing.expectEqual('newpass', url.password); + testing.expectEqual('https://user:newpass@example.com/path', url.href); + } + + { + const url = new URL('https://user:pass@example.com/path'); + url.username = ''; + url.password = ''; + testing.expectEqual('', url.username); + testing.expectEqual('', url.password); + testing.expectEqual('https://example.com/path', url.href); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user@domain'; + testing.expectEqual('user%40domain', url.username); + testing.expectEqual('https://user%40domain@example.com/path', url.href); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user:name'; + testing.expectEqual('user%3Aname', url.username); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass@word'; + testing.expectEqual('pass%40word', url.password); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass:word'; + testing.expectEqual('pass%3Aword', url.password); + } + + { + const url = new URL('https://example.com/path'); + url.username = 'user/name'; + testing.expectEqual('user%2Fname', url.username); + } + + { + const url = new URL('https://example.com/path'); + url.password = 'pass?word'; + testing.expectEqual('pass%3Fword', url.password); + } + + { + const url = new URL('https://user%40domain:pass%3Aword@example.com/path'); + testing.expectEqual('user%40domain', url.username); + testing.expectEqual('pass%3Aword', url.password); + } + + { + const url = new URL('https://example.com:8080/path?a=b#hash'); + url.username = 'user'; + url.password = 'pass'; + testing.expectEqual('https://user:pass@example.com:8080/path?a=b#hash', url.href); + testing.expectEqual('8080', url.port); + testing.expectEqual('?a=b', url.search); + testing.expectEqual('#hash', url.hash); + } + { const url = new URL('http://user:pass@example.com:8080/path?query=1#hash'); testing.expectEqual('http:', url.protocol); @@ -437,9 +537,9 @@ { const url = new URL('https://example.com:8080/path'); url.host = 'newhost.com'; - testing.expectEqual('https://newhost.com/path', url.href); + testing.expectEqual('https://newhost.com:8080/path', url.href); testing.expectEqual('newhost.com', url.hostname); - testing.expectEqual('', url.port); + testing.expectEqual('8080', url.port); } { diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index 0f2dc58b..3bc6f586 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -66,10 +66,20 @@ pub fn getUsername(self: *const URL) []const u8 { return U.getUsername(self._raw); } +pub fn setUsername(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setUsername(self._raw, value, allocator); +} + pub fn getPassword(self: *const URL) []const u8 { return U.getPassword(self._raw); } +pub fn setPassword(self: *URL, value: []const u8) !void { + const allocator = self._arena orelse return error.NoAllocator; + self._raw = try U.setPassword(self._raw, value, allocator); +} + pub fn getPathname(self: *const URL) []const u8 { return U.getPathname(self._raw); } @@ -272,8 +282,8 @@ pub const JsApi = struct { pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{}); pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{}); pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{}); - pub const username = bridge.accessor(URL.getUsername, null, .{}); - pub const password = bridge.accessor(URL.getPassword, null, .{}); + pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{}); + pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{}); pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{}); pub const host = bridge.accessor(URL.getHost, URL.setHost, .{}); pub const port = bridge.accessor(URL.getPort, URL.setPort, .{}); From 1399bd3065a4be6abe09cb81956f00ec50101110 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 17:47:59 +0800 Subject: [PATCH 02/10] Apply some normalization to CSS values "10px 10px" should present as "10px". A length of "0" should present as "0px" Fixes a handful of WPT tests. --- src/browser/tests/css/stylesheet.html | 66 ++++++ .../webapi/css/CSSStyleDeclaration.zig | 169 +++++++++++++- src/browser/webapi/css/CSSStyleProperties.zig | 209 +++++++++++++++++- 3 files changed, 430 insertions(+), 14 deletions(-) diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index 59f04d47..5e4f3b27 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -275,3 +275,69 @@ testing.expectEqual('red', div.style.getPropertyValue('color')); } + + + + + + diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index a8eb92e4..502e479f 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -26,6 +26,8 @@ const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); const Element = @import("../Element.zig"); +const Allocator = std.mem.Allocator; + const CSSStyleDeclaration = @This(); _element: ?*Element = null, @@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized = normalizePropertyName(property_name, &page.buf); + // Normalize the value for canonical serialization + const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value); + // Find existing property if (self.findProperty(normalized)) |existing| { - existing._value = try String.init(page.arena, value, .{}); + existing._value = try String.init(page.arena, normalized_value, .{}); existing._important = important; return; } @@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const prop = try page._factory.create(Property{ ._node = .{}, ._name = try String.init(page.arena, normalized, .{}), - ._value = try String.init(page.arena, value, .{}), + ._value = try String.init(page.arena, normalized_value, .{}), ._important = important, }); self._properties.append(&prop._node); @@ -227,6 +232,166 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 { return std.ascii.lowerString(buf, name); } +// Normalize CSS property values for canonical serialization +fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 { + // Per CSSOM spec, unitless zero in length properties should serialize as "0px" + if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) { + return "0px"; + } + + // "first baseline" serializes canonically as "baseline" (first is the default) + if (std.ascii.startsWithIgnoreCase(value, "first baseline")) { + if (value.len == 14) { + // Exact match "first baseline" + return "baseline"; + } + if (value.len > 14 and value[14] == ' ') { + // "first baseline X" -> "baseline X" + return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] }); + } + } + + // For 2-value shorthand properties, collapse "X X" to "X" + if (isTwoValueShorthand(property_name)) { + if (collapseDuplicateValue(value)) |single| { + return single; + } + } + + return value; +} + +// Check if a value is "X X" (duplicate) and return just "X" +fn collapseDuplicateValue(value: []const u8) ?[]const u8 { + const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; + if (space_idx == 0 or space_idx >= value.len - 1) return null; + + const first = value[0..space_idx]; + const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " "); + + // Check if there's only one more value (no additional spaces) + if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null; + + if (std.mem.eql(u8, first, rest)) { + return first; + } + return null; +} + +fn isTwoValueShorthand(name: []const u8) bool { + const shorthands = std.StaticStringMap(void).initComptime(.{ + .{ "place-content", {} }, + .{ "place-items", {} }, + .{ "place-self", {} }, + .{ "margin-block", {} }, + .{ "margin-inline", {} }, + .{ "padding-block", {} }, + .{ "padding-inline", {} }, + .{ "inset-block", {} }, + .{ "inset-inline", {} }, + .{ "border-block-style", {} }, + .{ "border-inline-style", {} }, + .{ "border-block-width", {} }, + .{ "border-inline-width", {} }, + .{ "border-block-color", {} }, + .{ "border-inline-color", {} }, + .{ "overflow", {} }, + .{ "overscroll-behavior", {} }, + .{ "gap", {} }, + }); + return shorthands.has(name); +} + +fn isLengthProperty(name: []const u8) bool { + // Properties that accept or values + const length_properties = std.StaticStringMap(void).initComptime(.{ + // Sizing + .{ "width", {} }, + .{ "height", {} }, + .{ "min-width", {} }, + .{ "min-height", {} }, + .{ "max-width", {} }, + .{ "max-height", {} }, + // Margins + .{ "margin", {} }, + .{ "margin-top", {} }, + .{ "margin-right", {} }, + .{ "margin-bottom", {} }, + .{ "margin-left", {} }, + .{ "margin-block", {} }, + .{ "margin-block-start", {} }, + .{ "margin-block-end", {} }, + .{ "margin-inline", {} }, + .{ "margin-inline-start", {} }, + .{ "margin-inline-end", {} }, + // Padding + .{ "padding", {} }, + .{ "padding-top", {} }, + .{ "padding-right", {} }, + .{ "padding-bottom", {} }, + .{ "padding-left", {} }, + .{ "padding-block", {} }, + .{ "padding-block-start", {} }, + .{ "padding-block-end", {} }, + .{ "padding-inline", {} }, + .{ "padding-inline-start", {} }, + .{ "padding-inline-end", {} }, + // Positioning + .{ "top", {} }, + .{ "right", {} }, + .{ "bottom", {} }, + .{ "left", {} }, + .{ "inset", {} }, + .{ "inset-block", {} }, + .{ "inset-block-start", {} }, + .{ "inset-block-end", {} }, + .{ "inset-inline", {} }, + .{ "inset-inline-start", {} }, + .{ "inset-inline-end", {} }, + // Border + .{ "border-width", {} }, + .{ "border-top-width", {} }, + .{ "border-right-width", {} }, + .{ "border-bottom-width", {} }, + .{ "border-left-width", {} }, + .{ "border-block-width", {} }, + .{ "border-block-start-width", {} }, + .{ "border-block-end-width", {} }, + .{ "border-inline-width", {} }, + .{ "border-inline-start-width", {} }, + .{ "border-inline-end-width", {} }, + .{ "border-radius", {} }, + .{ "border-top-left-radius", {} }, + .{ "border-top-right-radius", {} }, + .{ "border-bottom-left-radius", {} }, + .{ "border-bottom-right-radius", {} }, + // Text + .{ "font-size", {} }, + .{ "line-height", {} }, + .{ "letter-spacing", {} }, + .{ "word-spacing", {} }, + .{ "text-indent", {} }, + // Flexbox/Grid + .{ "gap", {} }, + .{ "row-gap", {} }, + .{ "column-gap", {} }, + .{ "flex-basis", {} }, + // Outline + .{ "outline-width", {} }, + .{ "outline-offset", {} }, + // Other + .{ "border-spacing", {} }, + .{ "text-shadow", {} }, + .{ "box-shadow", {} }, + .{ "baseline-shift", {} }, + .{ "vertical-align", {} }, + // Grid lanes + .{ "flow-tolerance", {} }, + }); + + return length_properties.has(name); +} + fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { if (std.mem.eql(u8, normalized_name, "visibility")) { return "visible"; diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig index 1bd51a4a..1b6def0e 100644 --- a/src/browser/webapi/css/CSSStyleProperties.zig +++ b/src/browser/webapi/css/CSSStyleProperties.zig @@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con } fn isKnownCSSProperty(dash_case: []const u8) bool { - // List of common/known CSS properties - // In a full implementation, this would include all standard CSS properties const known_properties = std.StaticStringMap(void).initComptime(.{ + // Colors & backgrounds .{ "color", {} }, + .{ "background", {} }, .{ "background-color", {} }, + .{ "background-image", {} }, + .{ "background-position", {} }, + .{ "background-repeat", {} }, + .{ "background-size", {} }, + .{ "background-attachment", {} }, + .{ "background-clip", {} }, + .{ "background-origin", {} }, + // Typography + .{ "font", {} }, + .{ "font-family", {} }, .{ "font-size", {} }, + .{ "font-style", {} }, + .{ "font-weight", {} }, + .{ "font-variant", {} }, + .{ "line-height", {} }, + .{ "letter-spacing", {} }, + .{ "word-spacing", {} }, + .{ "text-align", {} }, + .{ "text-decoration", {} }, + .{ "text-indent", {} }, + .{ "text-transform", {} }, + .{ "white-space", {} }, + .{ "word-break", {} }, + .{ "word-wrap", {} }, + .{ "overflow-wrap", {} }, + // Box model + .{ "margin", {} }, .{ "margin-top", {} }, + .{ "margin-right", {} }, .{ "margin-bottom", {} }, .{ "margin-left", {} }, - .{ "margin-right", {} }, + .{ "margin-block", {} }, + .{ "margin-block-start", {} }, + .{ "margin-block-end", {} }, + .{ "margin-inline", {} }, + .{ "margin-inline-start", {} }, + .{ "margin-inline-end", {} }, + .{ "padding", {} }, .{ "padding-top", {} }, + .{ "padding-right", {} }, .{ "padding-bottom", {} }, .{ "padding-left", {} }, - .{ "padding-right", {} }, + .{ "padding-block", {} }, + .{ "padding-block-start", {} }, + .{ "padding-block-end", {} }, + .{ "padding-inline", {} }, + .{ "padding-inline-start", {} }, + .{ "padding-inline-end", {} }, + // Border + .{ "border", {} }, + .{ "border-width", {} }, + .{ "border-style", {} }, + .{ "border-color", {} }, + .{ "border-top", {} }, + .{ "border-top-width", {} }, + .{ "border-top-style", {} }, + .{ "border-top-color", {} }, + .{ "border-right", {} }, + .{ "border-right-width", {} }, + .{ "border-right-style", {} }, + .{ "border-right-color", {} }, + .{ "border-bottom", {} }, + .{ "border-bottom-width", {} }, + .{ "border-bottom-style", {} }, + .{ "border-bottom-color", {} }, + .{ "border-left", {} }, + .{ "border-left-width", {} }, + .{ "border-left-style", {} }, + .{ "border-left-color", {} }, + .{ "border-radius", {} }, .{ "border-top-left-radius", {} }, .{ "border-top-right-radius", {} }, .{ "border-bottom-left-radius", {} }, .{ "border-bottom-right-radius", {} }, - .{ "float", {} }, - .{ "z-index", {} }, + .{ "border-collapse", {} }, + .{ "border-spacing", {} }, + // Sizing .{ "width", {} }, .{ "height", {} }, + .{ "min-width", {} }, + .{ "min-height", {} }, + .{ "max-width", {} }, + .{ "max-height", {} }, + .{ "box-sizing", {} }, + // Positioning + .{ "position", {} }, + .{ "top", {} }, + .{ "right", {} }, + .{ "bottom", {} }, + .{ "left", {} }, + .{ "inset", {} }, + .{ "inset-block", {} }, + .{ "inset-block-start", {} }, + .{ "inset-block-end", {} }, + .{ "inset-inline", {} }, + .{ "inset-inline-start", {} }, + .{ "inset-inline-end", {} }, + .{ "z-index", {} }, + .{ "float", {} }, + .{ "clear", {} }, + // Display & visibility .{ "display", {} }, .{ "visibility", {} }, .{ "opacity", {} }, - .{ "filter", {} }, + .{ "overflow", {} }, + .{ "overflow-x", {} }, + .{ "overflow-y", {} }, + .{ "clip", {} }, + .{ "clip-path", {} }, + // Flexbox + .{ "flex", {} }, + .{ "flex-direction", {} }, + .{ "flex-wrap", {} }, + .{ "flex-flow", {} }, + .{ "flex-grow", {} }, + .{ "flex-shrink", {} }, + .{ "flex-basis", {} }, + .{ "order", {} }, + // Grid + .{ "grid", {} }, + .{ "grid-template", {} }, + .{ "grid-template-columns", {} }, + .{ "grid-template-rows", {} }, + .{ "grid-template-areas", {} }, + .{ "grid-auto-columns", {} }, + .{ "grid-auto-rows", {} }, + .{ "grid-auto-flow", {} }, + .{ "grid-column", {} }, + .{ "grid-column-start", {} }, + .{ "grid-column-end", {} }, + .{ "grid-row", {} }, + .{ "grid-row-start", {} }, + .{ "grid-row-end", {} }, + .{ "grid-area", {} }, + .{ "gap", {} }, + .{ "row-gap", {} }, + .{ "column-gap", {} }, + // Alignment (flexbox & grid) + .{ "align-content", {} }, + .{ "align-items", {} }, + .{ "align-self", {} }, + .{ "justify-content", {} }, + .{ "justify-items", {} }, + .{ "justify-self", {} }, + .{ "place-content", {} }, + .{ "place-items", {} }, + .{ "place-self", {} }, + // Transforms & animations .{ "transform", {} }, + .{ "transform-origin", {} }, + .{ "transform-style", {} }, + .{ "perspective", {} }, + .{ "perspective-origin", {} }, .{ "transition", {} }, - .{ "position", {} }, - .{ "top", {} }, - .{ "bottom", {} }, - .{ "left", {} }, - .{ "right", {} }, + .{ "transition-property", {} }, + .{ "transition-duration", {} }, + .{ "transition-timing-function", {} }, + .{ "transition-delay", {} }, + .{ "animation", {} }, + .{ "animation-name", {} }, + .{ "animation-duration", {} }, + .{ "animation-timing-function", {} }, + .{ "animation-delay", {} }, + .{ "animation-iteration-count", {} }, + .{ "animation-direction", {} }, + .{ "animation-fill-mode", {} }, + .{ "animation-play-state", {} }, + // Filters & effects + .{ "filter", {} }, + .{ "backdrop-filter", {} }, + .{ "box-shadow", {} }, + .{ "text-shadow", {} }, + // Outline + .{ "outline", {} }, + .{ "outline-width", {} }, + .{ "outline-style", {} }, + .{ "outline-color", {} }, + .{ "outline-offset", {} }, + // Lists + .{ "list-style", {} }, + .{ "list-style-type", {} }, + .{ "list-style-position", {} }, + .{ "list-style-image", {} }, + // Tables + .{ "table-layout", {} }, + .{ "caption-side", {} }, + .{ "empty-cells", {} }, + // Misc + .{ "cursor", {} }, + .{ "pointer-events", {} }, + .{ "user-select", {} }, + .{ "resize", {} }, + .{ "object-fit", {} }, + .{ "object-position", {} }, + .{ "vertical-align", {} }, + .{ "content", {} }, + .{ "quotes", {} }, + .{ "counter-reset", {} }, + .{ "counter-increment", {} }, + // Scrolling + .{ "scroll-behavior", {} }, + .{ "scroll-margin", {} }, + .{ "scroll-padding", {} }, + .{ "overscroll-behavior", {} }, + .{ "overscroll-behavior-x", {} }, + .{ "overscroll-behavior-y", {} }, + // Containment + .{ "contain", {} }, + .{ "container", {} }, + .{ "container-type", {} }, + .{ "container-name", {} }, + // Aspect ratio + .{ "aspect-ratio", {} }, }); return known_properties.has(dash_case); From 3ad10ff8d0595bbea5f6c40e637ae1b94e0ed193 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 9 Mar 2026 18:25:01 +0800 Subject: [PATCH 03/10] Add support for normalization anchor-size css value vibed this. Seems esoteric, but it helps over 1000 WPT cases pass in /css/css-anchor-position/anchor-size-parse-valid.html --- src/browser/tests/css/stylesheet.html | 40 ++++ .../webapi/css/CSSStyleDeclaration.zig | 185 ++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/src/browser/tests/css/stylesheet.html b/src/browser/tests/css/stylesheet.html index 5e4f3b27..e717e806 100644 --- a/src/browser/tests/css/stylesheet.html +++ b/src/browser/tests/css/stylesheet.html @@ -341,3 +341,43 @@ testing.expectEqual('10px 20px', div.style.gap); } + + diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 502e479f..adc8982a 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -258,9 +258,194 @@ fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: [] } } + // Canonicalize anchor-size() function: anchor name (dashed ident) comes before size keyword + if (std.mem.indexOf(u8, value, "anchor-size(") != null) { + return try canonicalizeAnchorSize(arena, value); + } + return value; } +// Canonicalize anchor-size() so that the dashed ident (anchor name) comes before the size keyword. +// e.g. "anchor-size(width --foo)" -> "anchor-size(--foo width)" +fn canonicalizeAnchorSize(arena: Allocator, value: []const u8) ![]const u8 { + var buf = std.Io.Writer.Allocating.init(arena); + var i: usize = 0; + + while (i < value.len) { + // Look for "anchor-size(" + if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { + try buf.writer.writeAll("anchor-size("); + i += "anchor-size(".len; + + // Parse and canonicalize the arguments + i = try canonicalizeAnchorSizeArgs(value, i, &buf.writer); + } else { + try buf.writer.writeByte(value[i]); + i += 1; + } + } + + return buf.written(); +} + +// Parse anchor-size arguments and write them in canonical order +fn canonicalizeAnchorSizeArgs(value: []const u8, start: usize, writer: *std.Io.Writer) !usize { + var i = start; + var depth: usize = 1; + + // Skip leading whitespace + while (i < value.len and value[i] == ' ') : (i += 1) {} + + // Collect tokens before the comma or close paren + var first_token_start: ?usize = null; + var first_token_end: usize = 0; + var second_token_start: ?usize = null; + var second_token_end: usize = 0; + var comma_pos: ?usize = null; + var token_count: usize = 0; + + const args_start = i; + var in_token = false; + + // First pass: find the structure of arguments before comma/closing paren at depth 1 + while (i < value.len and depth > 0) { + const c = value[i]; + + if (c == '(') { + depth += 1; + in_token = true; + i += 1; + } else if (c == ')') { + depth -= 1; + if (depth == 0) { + if (in_token) { + if (token_count == 0) { + first_token_end = i; + } else if (token_count == 1) { + second_token_end = i; + } + } + break; + } + i += 1; + } else if (c == ',' and depth == 1) { + if (in_token) { + if (token_count == 0) { + first_token_end = i; + } else if (token_count == 1) { + second_token_end = i; + } + } + comma_pos = i; + break; + } else if (c == ' ') { + if (in_token and depth == 1) { + if (token_count == 0) { + first_token_end = i; + token_count = 1; + } else if (token_count == 1 and second_token_start != null) { + second_token_end = i; + token_count = 2; + } + in_token = false; + } + i += 1; + } else { + if (!in_token and depth == 1) { + if (token_count == 0) { + first_token_start = i; + } else if (token_count == 1) { + second_token_start = i; + } + in_token = true; + } + i += 1; + } + } + + // Handle end of tokens + if (in_token and token_count == 1 and second_token_start != null) { + second_token_end = i; + token_count = 2; + } else if (in_token and token_count == 0) { + first_token_end = i; + token_count = 1; + } + + // Check if we have exactly two tokens that need reordering + if (token_count == 2) { + const first_start = first_token_start orelse args_start; + const second_start = second_token_start orelse first_token_end; + + const first_token = value[first_start..first_token_end]; + const second_token = value[second_start..second_token_end]; + + // If second token is a dashed ident and first is a size keyword, swap them + if (std.mem.startsWith(u8, second_token, "--") and isAnchorSizeKeyword(first_token)) { + try writer.writeAll(second_token); + try writer.writeByte(' '); + try writer.writeAll(first_token); + } else { + // Keep original order + try writer.writeAll(first_token); + try writer.writeByte(' '); + try writer.writeAll(second_token); + } + } else if (first_token_start) |fts| { + // Single token, just copy it + try writer.writeAll(value[fts..first_token_end]); + } + + // Handle comma and fallback value (may contain nested anchor-size) + if (comma_pos) |cp| { + try writer.writeAll(", "); + i = cp + 1; + // Skip whitespace after comma + while (i < value.len and value[i] == ' ') : (i += 1) {} + + // Copy the fallback, recursively handling nested anchor-size + while (i < value.len and depth > 0) { + if (std.mem.startsWith(u8, value[i..], "anchor-size(")) { + try writer.writeAll("anchor-size("); + i += "anchor-size(".len; + depth += 1; + i = try canonicalizeAnchorSizeArgs(value, i, writer); + depth -= 1; + } else if (value[i] == '(') { + depth += 1; + try writer.writeByte(value[i]); + i += 1; + } else if (value[i] == ')') { + depth -= 1; + if (depth == 0) break; + try writer.writeByte(value[i]); + i += 1; + } else { + try writer.writeByte(value[i]); + i += 1; + } + } + } + + // Write closing paren + try writer.writeByte(')'); + + return i + 1; // Skip past the closing paren +} + +fn isAnchorSizeKeyword(token: []const u8) bool { + const keywords = std.StaticStringMap(void).initComptime(.{ + .{ "width", {} }, + .{ "height", {} }, + .{ "block", {} }, + .{ "inline", {} }, + .{ "self-block", {} }, + .{ "self-inline", {} }, + }); + return keywords.has(token); +} + // Check if a value is "X X" (duplicate) and return just "X" fn collapseDuplicateValue(value: []const u8) ?[]const u8 { const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null; From 33d75354a2ae800bdfc459151164c5a4835ba276 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 09:05:06 +0800 Subject: [PATCH 04/10] Add new Response and Request methods -Response.blob -Response.clone -Request.blob -Request.text -Request.json -Request.arrayBuffer -Request.bytes -Request.clone --- src/browser/tests/blob.html | 58 +++++++++++ src/browser/tests/net/request.html | 76 ++++++++++++++ src/browser/tests/net/response.html | 150 ++++++++++++++++++++-------- src/browser/webapi/Blob.zig | 36 ++++++- src/browser/webapi/net/Request.zig | 56 +++++++++++ src/browser/webapi/net/Response.zig | 45 +++++++++ 6 files changed, 374 insertions(+), 47 deletions(-) diff --git a/src/browser/tests/blob.html b/src/browser/tests/blob.html index 12cd13f5..0cbf8ea5 100644 --- a/src/browser/tests/blob.html +++ b/src/browser/tests/blob.html @@ -98,6 +98,64 @@ } + + + + + + diff --git a/src/browser/tests/net/response.html b/src/browser/tests/net/response.html index b7c149ba..3b74e72c 100644 --- a/src/browser/tests/net/response.html +++ b/src/browser/tests/net/response.html @@ -2,51 +2,113 @@ - - diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index ac31560a..aa955ce5 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -21,6 +21,7 @@ const Writer = std.Io.Writer; const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Mime = @import("../Mime.zig"); /// https://w3c.github.io/FileAPI/#blob-section /// https://developer.mozilla.org/en-US/docs/Web/API/Blob @@ -50,21 +51,50 @@ const InitOptions = struct { endings: []const u8 = "transparent", }; -/// Creates a new Blob. +/// Creates a new Blob (JS constructor). pub fn init( maybe_blob_parts: ?[]const []const u8, maybe_options: ?InitOptions, page: *Page, +) !*Blob { + return initWithMimeValidation(maybe_blob_parts, maybe_options, false, page); +} + +/// Creates a new Blob with optional MIME validation. +/// When validate_mime is true, uses full MIME parsing (for Response/Request). +/// When false, uses simple ASCII validation per FileAPI spec (for Blob constructor). +pub fn initWithMimeValidation( + maybe_blob_parts: ?[]const []const u8, + maybe_options: ?InitOptions, + validate_mime: bool, + page: *Page, ) !*Blob { const options: InitOptions = maybe_options orelse .{}; - // Setup MIME; This can be any string according to my observations. + const mime: []const u8 = blk: { const t = options.type; if (t.len == 0) { break :blk ""; } - break :blk try page.arena.dupe(u8, t); + const buf = try page.arena.dupe(u8, t); + + if (validate_mime) { + // Full MIME parsing per MIME sniff spec (for Content-Type headers) + _ = Mime.parse(buf) catch break :blk ""; + } else { + // Simple validation per FileAPI spec (for Blob constructor): + // - If any char is outside U+0020-U+007E, return empty string + // - Otherwise lowercase + for (t) |c| { + if (c < 0x20 or c > 0x7E) { + break :blk ""; + } + } + _ = std.ascii.lowerString(buf, buf); + } + + break :blk buf; }; const data = blk: { diff --git a/src/browser/webapi/net/Request.zig b/src/browser/webapi/net/Request.zig index 4316ddbb..aa7e0dd7 100644 --- a/src/browser/webapi/net/Request.zig +++ b/src/browser/webapi/net/Request.zig @@ -24,6 +24,7 @@ const Http = @import("../../../http/Http.zig"); const URL = @import("../URL.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); +const Blob = @import("../Blob.zig"); const Allocator = std.mem.Allocator; const Request = @This(); @@ -153,6 +154,55 @@ pub fn getHeaders(self: *Request, page: *Page) !*Headers { return headers; } +pub fn blob(self: *Request, page: *Page) !js.Promise { + const body = self._body orelse ""; + const headers = try self.getHeaders(page); + const content_type = try headers.get("content-type", page) orelse ""; + + const b = try Blob.initWithMimeValidation( + &.{body}, + .{ .type = content_type }, + true, + page, + ); + + return page.js.local.?.resolvePromise(b); +} + +pub fn text(self: *const Request, page: *Page) !js.Promise { + const body = self._body orelse ""; + return page.js.local.?.resolvePromise(body); +} + +pub fn json(self: *const Request, page: *Page) !js.Promise { + const body = self._body orelse ""; + const local = page.js.local.?; + const value = local.parseJSON(body) catch |err| { + return local.rejectPromise(.{@errorName(err)}); + }; + return local.resolvePromise(try value.persist()); +} + +pub fn arrayBuffer(self: *const Request, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); +} + +pub fn bytes(self: *const Request, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); +} + +pub fn clone(self: *const Request, page: *Page) !*Request { + return page._factory.create(Request{ + ._url = self._url, + ._arena = self._arena, + ._method = self._method, + ._headers = self._headers, + ._cache = self._cache, + ._credentials = self._credentials, + ._body = self._body, + }); +} + pub const JsApi = struct { pub const bridge = js.Bridge(Request); @@ -168,6 +218,12 @@ pub const JsApi = struct { pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const credentials = bridge.accessor(Request.getCredentials, null, .{}); + pub const blob = bridge.function(Request.blob, .{}); + pub const text = bridge.function(Request.text, .{}); + pub const json = bridge.function(Request.json, .{}); + pub const arrayBuffer = bridge.function(Request.arrayBuffer, .{}); + pub const bytes = bridge.function(Request.bytes, .{}); + pub const clone = bridge.function(Request.clone, .{}); }; const testing = @import("../../../testing.zig"); diff --git a/src/browser/webapi/net/Response.zig b/src/browser/webapi/net/Response.zig index ba1a754d..d2c270ce 100644 --- a/src/browser/webapi/net/Response.zig +++ b/src/browser/webapi/net/Response.zig @@ -23,6 +23,7 @@ const Http = @import("../../../http/Http.zig"); const Page = @import("../../Page.zig"); const Headers = @import("Headers.zig"); const ReadableStream = @import("../streams/ReadableStream.zig"); +const Blob = @import("../Blob.zig"); const Allocator = std.mem.Allocator; @@ -147,6 +148,47 @@ pub fn arrayBuffer(self: *const Response, page: *Page) !js.Promise { return page.js.local.?.resolvePromise(js.ArrayBuffer{ .values = self._body orelse "" }); } +pub fn blob(self: *const Response, page: *Page) !js.Promise { + const body = self._body orelse ""; + const content_type = try self._headers.get("content-type", page) orelse ""; + + const b = try Blob.initWithMimeValidation( + &.{body}, + .{ .type = content_type }, + true, + page, + ); + + return page.js.local.?.resolvePromise(b); +} + +pub fn bytes(self: *const Response, page: *Page) !js.Promise { + return page.js.local.?.resolvePromise(js.TypedArray(u8){ .values = self._body orelse "" }); +} + +pub fn clone(self: *const Response, page: *Page) !*Response { + const arena = try page.getArena(.{ .debug = "Response.clone" }); + errdefer page.releaseArena(arena); + + const body = if (self._body) |b| try arena.dupe(u8, b) else null; + const status_text = try arena.dupe(u8, self._status_text); + const url = try arena.dupeZ(u8, self._url); + + const cloned = try arena.create(Response); + cloned.* = .{ + ._arena = arena, + ._status = self._status, + ._status_text = status_text, + ._url = url, + ._body = body, + ._type = self._type, + ._is_redirected = self._is_redirected, + ._headers = try Headers.init(.{ .obj = self._headers }, page), + ._transfer = null, + }; + return cloned; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Response); @@ -170,6 +212,9 @@ pub const JsApi = struct { pub const url = bridge.accessor(Response.getURL, null, .{}); pub const redirected = bridge.accessor(Response.isRedirected, null, .{}); pub const arrayBuffer = bridge.function(Response.arrayBuffer, .{}); + pub const blob = bridge.function(Response.blob, .{}); + pub const bytes = bridge.function(Response.bytes, .{}); + pub const clone = bridge.function(Response.clone, .{}); }; const testing = @import("../../../testing.zig"); From 8eeb34dba8a8d78bdfa2f06ff1294c6869d2437b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 13:42:54 +0800 Subject: [PATCH 05/10] Node matching using tag name string comparison on non-HTML nodes NodeLive (used by, e.g. getElementsByTagName) needs to revert to the non- optimized string-comparison for non-HTML tags. This should help fix https://github.com/lightpanda-io/browser/issues/1214 --- src/browser/tests/domparser.html | 22 ++++++++++++++++++++ src/browser/webapi/collections/node_live.zig | 9 +++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/browser/tests/domparser.html b/src/browser/tests/domparser.html index 24d34e89..7930ec87 100644 --- a/src/browser/tests/domparser.html +++ b/src/browser/tests/domparser.html @@ -397,3 +397,25 @@ } } + + diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig index 65fdfbf7..a55420e7 100644 --- a/src/browser/webapi/collections/node_live.zig +++ b/src/browser/webapi/collections/node_live.zig @@ -219,7 +219,14 @@ pub fn NodeLive(comptime mode: Mode) type { switch (mode) { .tag => { const el = node.is(Element) orelse return false; - return el.getTag() == self._filter; + // For HTML namespace elements, we can use the optimized tag comparison. + // For other namespaces (XML, SVG custom elements, etc.), fall back to string comparison. + if (el._namespace == .html) { + return el.getTag() == self._filter; + } + // For non-HTML elements, compare by tag name string + const element_tag = el.getTagNameLower(); + return std.mem.eql(u8, element_tag, @tagName(self._filter)); }, .tag_name => { // If we're in `tag_name` mode, then the tag_name isn't From c4e85c327747500e383658280a0769a8ad15e890 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 14:57:40 +0800 Subject: [PATCH 06/10] Add a hasDirectListeners to EventManager Allows checking if a direct listener exists, if it doesn't, event creation can be skipped. I looked at a couple sites, the benefits of this is small. Most sites don't seem to trigger that many direct dispatches and when they do, they seem to have a listener 50-75% of the time. --- src/browser/EventManager.zig | 23 +++++++++++++++++ src/browser/Page.zig | 29 +++++++++------------- src/browser/webapi/AbortSignal.zig | 12 ++++----- src/browser/webapi/EventTarget.zig | 1 + src/browser/webapi/History.zig | 12 ++++----- src/browser/webapi/MessagePort.zig | 30 +++++++++++------------ src/browser/webapi/Window.zig | 19 ++++++-------- src/browser/webapi/net/XMLHttpRequest.zig | 12 ++++----- 8 files changed, 73 insertions(+), 65 deletions(-) diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 17271635..573aa4f9 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -365,6 +365,29 @@ fn getFunction(handler: anytype, local: *const js.Local) ?js.Function { }; } +/// Check if there are any listeners for a direct dispatch (non-DOM target). +/// Use this to avoid creating an event when there are no listeners. +pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool { + if (hasHandler(handler)) { + return true; + } + return self.lookup.get(.{ + .event_target = @intFromPtr(target), + .type_string = .wrap(typ), + }) != null; +} + +fn hasHandler(handler: anytype) bool { + const ti = @typeInfo(@TypeOf(handler)); + if (ti == .null) { + return false; + } + if (ti == .optional) { + return handler != null; + } + return true; +} + fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void { const ShadowRoot = @import("webapi/ShadowRoot.zig"); diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 0cc7d8d9..014ebb62 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -791,24 +791,19 @@ fn _documentIsComplete(self: *Page) !void { try self.dispatchLoad(); // Dispatch window.load event. - const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); - // This event is weird, it's dispatched directly on the window, but - // with the document as the target. - event._target = self.document.asEventTarget(); - try self._event_manager.dispatchDirect( - self.window.asEventTarget(), - event, - self.window._on_load, - .{ .inject_target = false, .context = "page load" }, - ); + const window_target = self.window.asEventTarget(); + if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) { + const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); + // This event is weird, it's dispatched directly on the window, but + // with the document as the target. + event._target = self.document.asEventTarget(); + try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" }); + } - const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); - try self._event_manager.dispatchDirect( - self.window.asEventTarget(), - pageshow_event, - self.window._on_pageshow, - .{ .context = "page show" }, - ); + if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) { + const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); + try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" }); + } self.notifyParentLoadComplete(); } diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index de685efc..24bd2a7e 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -76,13 +76,11 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void { } // Dispatch abort event - const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); - try page._event_manager.dispatchDirect( - self.asEventTarget(), - event, - self._on_abort, - .{ .context = "abort signal" }, - ); + const target = self.asEventTarget(); + if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) { + const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); + try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" }); + } } // Static method to create an already-aborted signal diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index 3fd78f8b..31f472c3 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -138,6 +138,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void { .screen => writer.writeAll(""), .screen_orientation => writer.writeAll(""), .visual_viewport => writer.writeAll(""), + .file_reader => writer.writeAll(""), }; } diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig index b8819708..12336f2c 100644 --- a/src/browser/webapi/History.zig +++ b/src/browser/webapi/History.zig @@ -79,13 +79,11 @@ fn goInner(delta: i32, page: *Page) !void { if (entry._url) |url| { if (try page.isSameOrigin(url)) { - const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); - try page._event_manager.dispatchDirect( - page.window.asEventTarget(), - event, - page.window._on_popstate, - .{ .context = "Pop State" }, - ); + const target = page.window.asEventTarget(); + if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) { + const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); + try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" }); + } } } diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig index dfe031f7..51d208b0 100644 --- a/src/browser/webapi/MessagePort.zig +++ b/src/browser/webapi/MessagePort.zig @@ -122,23 +122,21 @@ const PostMessageCallback = struct { return null; } - const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ - .data = self.message, - .origin = "", - .source = null, - }, page) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{ .err = err }); - return null; - }).asEvent(); + const target = self.port.asEventTarget(); + if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) { + const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ + .data = self.message, + .origin = "", + .source = null, + }, page) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); + return null; + }).asEvent(); - page._event_manager.dispatchDirect( - self.port.asEventTarget(), - event, - self.port._on_message, - .{ .context = "MessagePort message" }, - ) catch |err| { - log.err(.dom, "MessagePort.postMessage", .{ .err = err }); - }; + page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| { + log.err(.dom, "MessagePort.postMessage", .{ .err = err }); + }; + } return null; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 4d445e07..40265bb5 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -551,17 +551,14 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection, }); } - const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ - .reason = if (rejection.reason()) |r| try r.temp() else null, - .promise = try rejection.promise().temp(), - }, page)).asEvent(); - - try page._event_manager.dispatchDirect( - self.asEventTarget(), - event, - self._on_unhandled_rejection, - .{ .inject_target = true, .context = "window.unhandledrejection" }, - ); + const target = self.asEventTarget(); + if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) { + const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ + .reason = if (rejection.reason()) |r| try r.temp() else null, + .promise = try rejection.promise().temp(), + }, page)).asEvent(); + try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" }); + } } const ScheduleOpts = struct { diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index c1380908..bf442c13 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -508,13 +508,11 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void { self._ready_state = state; - const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page); - try page._event_manager.dispatchDirect( - self.asEventTarget(), - event, - self._on_ready_state_change, - .{ .context = "XHR state change" }, - ); + const target = self.asEventTarget(); + if (page._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) { + const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page); + try page._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" }); + } } fn parseMethod(method: []const u8) !Http.Method { From 343d985e967ce808c44f3bdc2f5210d4cc08e592 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 10 Mar 2026 15:40:18 +0800 Subject: [PATCH 07/10] Better support for variadic js.Value parameter (e.g. console.log) The bridge will prefer to map a Zig array to a JS Array, but in the case of a []js.Value, it should be willing to map anything into it. --- src/browser/js/Caller.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 01adc4f2..297d4ac1 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -734,7 +734,7 @@ fn getArgs(comptime F: type, comptime offset: usize, local: *const Local, info: if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) { const slice_type = last_parameter_type_info.pointer.child; const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local); - if (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8) { + if (slice_type == js.Value or (corresponding_js_value.isArray() == false and corresponding_js_value.isTypedArray() == false and slice_type != u8)) { is_variadic = true; if (js_parameter_count == 0) { @field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{}; From 22d31b1527a42a2ccc8ed2baf9cde857d4b60597 Mon Sep 17 00:00:00 2001 From: egrs Date: Tue, 10 Mar 2026 09:19:51 +0100 Subject: [PATCH 08/10] add LP.getStructuredData CDP command --- src/browser/structured_data.zig | 435 ++++++++++++++++++++++++++++++++ src/cdp/domains/lp.zig | 34 +++ src/lightpanda.zig | 1 + 3 files changed, 470 insertions(+) create mode 100644 src/browser/structured_data.zig diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig new file mode 100644 index 00000000..4335fb88 --- /dev/null +++ b/src/browser/structured_data.zig @@ -0,0 +1,435 @@ +// 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 std = @import("std"); + +const Page = @import("Page.zig"); +const URL = @import("URL.zig"); +const TreeWalker = @import("webapi/TreeWalker.zig"); +const Element = @import("webapi/Element.zig"); +const Node = @import("webapi/Node.zig"); + +const Allocator = std.mem.Allocator; + +/// Key-value pair for structured data properties. +pub const Property = struct { + key: []const u8, + value: []const u8, +}; + +pub const AlternateLink = struct { + href: []const u8, + hreflang: ?[]const u8, + type: ?[]const u8, + title: ?[]const u8, +}; + +pub const StructuredData = struct { + json_ld: []const []const u8, + open_graph: []const Property, + twitter_card: []const Property, + meta: []const Property, + links: []const Property, + alternate: []const AlternateLink, + + pub fn jsonStringify(self: *const StructuredData, jw: anytype) !void { + try jw.beginObject(); + + try jw.objectField("jsonLd"); + try jw.write(self.json_ld); + + try jw.objectField("openGraph"); + try writeProperties(jw, self.open_graph); + + try jw.objectField("twitterCard"); + try writeProperties(jw, self.twitter_card); + + try jw.objectField("meta"); + try writeProperties(jw, self.meta); + + try jw.objectField("links"); + try writeProperties(jw, self.links); + + if (self.alternate.len > 0) { + try jw.objectField("alternate"); + try jw.beginArray(); + for (self.alternate) |alt| { + try jw.beginObject(); + try jw.objectField("href"); + try jw.write(alt.href); + if (alt.hreflang) |v| { + try jw.objectField("hreflang"); + try jw.write(v); + } + if (alt.type) |v| { + try jw.objectField("type"); + try jw.write(v); + } + if (alt.title) |v| { + try jw.objectField("title"); + try jw.write(v); + } + try jw.endObject(); + } + try jw.endArray(); + } + + try jw.endObject(); + } +}; + +fn writeProperties(jw: anytype, properties: []const Property) !void { + try jw.beginObject(); + for (properties) |prop| { + try jw.objectField(prop.key); + try jw.write(prop.value); + } + try jw.endObject(); +} + +/// Extract all structured data from the page. +pub fn collectStructuredData( + root: *Node, + arena: Allocator, + page: *Page, +) !StructuredData { + var json_ld: std.ArrayList([]const u8) = .empty; + var open_graph: std.ArrayList(Property) = .empty; + var twitter_card: std.ArrayList(Property) = .empty; + var meta: std.ArrayList(Property) = .empty; + var links: std.ArrayList(Property) = .empty; + var alternate: std.ArrayList(AlternateLink) = .empty; + + // Extract language from the root element. + if (root.is(Element)) |root_el| { + if (root_el.getAttributeSafe(comptime .wrap("lang"))) |lang| { + try meta.append(arena, .{ .key = "language", .value = lang }); + } + } else { + // Root is document — check documentElement. + var children = root.childrenIterator(); + while (children.next()) |child| { + const el = child.is(Element) orelse continue; + if (el.getTag() == .html) { + if (el.getAttributeSafe(comptime .wrap("lang"))) |lang| { + try meta.append(arena, .{ .key = "language", .value = lang }); + } + break; + } + } + } + + var tw = TreeWalker.Full.init(root, .{}); + while (tw.next()) |node| { + const el = node.is(Element) orelse continue; + + switch (el.getTag()) { + .script => { + try collectJsonLd(el, arena, &json_ld); + tw.skipChildren(); + }, + .meta => collectMeta(el, &open_graph, &twitter_card, &meta, arena) catch {}, + .title => try collectTitle(node, arena, &meta), + .link => try collectLink(el, arena, page, &links, &alternate), + // Skip body subtree for non-JSON-LD — all other metadata is in . + // JSON-LD can appear in so we don't skip the whole body. + else => {}, + } + } + + return .{ + .json_ld = json_ld.items, + .open_graph = open_graph.items, + .twitter_card = twitter_card.items, + .meta = meta.items, + .links = links.items, + .alternate = alternate.items, + }; +} + +fn collectJsonLd( + el: *Element, + arena: Allocator, + json_ld: *std.ArrayList([]const u8), +) !void { + const type_attr = el.getAttributeSafe(comptime .wrap("type")) orelse return; + if (!std.ascii.eqlIgnoreCase(type_attr, "application/ld+json")) return; + + var buf: std.Io.Writer.Allocating = .init(arena); + try el.asNode().getTextContent(&buf.writer); + const text = buf.written(); + if (text.len > 0) { + try json_ld.append(arena, std.mem.trim(u8, text, &std.ascii.whitespace)); + } +} + +fn collectMeta( + el: *Element, + open_graph: *std.ArrayList(Property), + twitter_card: *std.ArrayList(Property), + meta: *std.ArrayList(Property), + arena: Allocator, +) !void { + // charset: (no content attribute needed). + if (el.getAttributeSafe(comptime .wrap("charset"))) |charset| { + try meta.append(arena, .{ .key = "charset", .value = charset }); + } + + const content = el.getAttributeSafe(comptime .wrap("content")) orelse return; + + // Open Graph: + if (el.getAttributeSafe(comptime .wrap("property"))) |property| { + if (startsWith(property, "og:")) { + try open_graph.append(arena, .{ .key = property[3..], .value = content }); + return; + } + // Article, profile, etc. are OG sub-namespaces. + if (startsWith(property, "article:") or + startsWith(property, "profile:") or + startsWith(property, "book:") or + startsWith(property, "music:") or + startsWith(property, "video:")) + { + try open_graph.append(arena, .{ .key = property, .value = content }); + return; + } + } + + // Twitter Cards: + if (el.getAttributeSafe(comptime .wrap("name"))) |name| { + if (startsWith(name, "twitter:")) { + try twitter_card.append(arena, .{ .key = name[8..], .value = content }); + return; + } + + // Standard meta tags by name. + const known_names = [_][]const u8{ + "description", "author", "keywords", "robots", + "viewport", "generator", "theme-color", + }; + for (known_names) |known| { + if (std.ascii.eqlIgnoreCase(name, known)) { + try meta.append(arena, .{ .key = known, .value = content }); + return; + } + } + } + + // http-equiv (e.g. Content-Type, refresh) + if (el.getAttributeSafe(comptime .wrap("http-equiv"))) |http_equiv| { + try meta.append(arena, .{ .key = http_equiv, .value = content }); + } +} + +fn collectTitle( + node: *Node, + arena: Allocator, + meta: *std.ArrayList(Property), +) !void { + var buf: std.Io.Writer.Allocating = .init(arena); + try node.getTextContent(&buf.writer); + const text = std.mem.trim(u8, buf.written(), &std.ascii.whitespace); + if (text.len > 0) { + try meta.append(arena, .{ .key = "title", .value = text }); + } +} + +fn collectLink( + el: *Element, + arena: Allocator, + page: *Page, + links: *std.ArrayList(Property), + alternate: *std.ArrayList(AlternateLink), +) !void { + const rel = el.getAttributeSafe(comptime .wrap("rel")) orelse return; + const raw_href = el.getAttributeSafe(comptime .wrap("href")) orelse return; + const href = URL.resolve(arena, page.base(), raw_href, .{ .encode = true }) catch raw_href; + + if (std.ascii.eqlIgnoreCase(rel, "alternate")) { + try alternate.append(arena, .{ + .href = href, + .hreflang = el.getAttributeSafe(comptime .wrap("hreflang")), + .type = el.getAttributeSafe(comptime .wrap("type")), + .title = el.getAttributeSafe(comptime .wrap("title")), + }); + return; + } + + const relevant_rels = [_][]const u8{ + "canonical", "icon", "manifest", "shortcut icon", + "apple-touch-icon", "search", "author", "license", + "dns-prefetch", "preconnect", + }; + for (relevant_rels) |known| { + if (std.ascii.eqlIgnoreCase(rel, known)) { + try links.append(arena, .{ .key = known, .value = href }); + return; + } + } +} + +fn startsWith(haystack: []const u8, prefix: []const u8) bool { + if (haystack.len < prefix.len) return false; + return std.mem.eql(u8, haystack[0..prefix.len], prefix); +} + +// --- Tests --- + +const testing = @import("../testing.zig"); + +fn testStructuredData(html: []const u8) !StructuredData { + const page = try testing.test_session.createPage(); + defer testing.test_session.removePage(); + + const doc = page.window._document; + const div = try doc.createElement("div", null, page); + try page.parseHtmlAsChildren(div.asNode(), html); + + return collectStructuredData(div.asNode(), page.call_arena, page); +} + +fn findProperty(props: []const Property, key: []const u8) ?[]const u8 { + for (props) |p| { + if (std.mem.eql(u8, p.key, key)) return p.value; + } + return null; +} + +test "structured_data: json-ld" { + const data = try testStructuredData( + \\ + ); + try testing.expectEqual(1, data.json_ld.len); + try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null); +} + +test "structured_data: multiple json-ld" { + const data = try testStructuredData( + \\ + \\ + \\ + ); + try testing.expectEqual(2, data.json_ld.len); +} + +test "structured_data: open graph" { + const data = try testStructuredData( + \\ + \\ + \\ + \\ + \\ + \\ + ); + try testing.expectEqual(6, data.open_graph.len); + try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?); + try testing.expectEqual("article", findProperty(data.open_graph, "type").?); + try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?); +} + +test "structured_data: twitter card" { + const data = try testStructuredData( + \\ + \\ + \\ + ); + try testing.expectEqual(3, data.twitter_card.len); + try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?); + try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?); +} + +test "structured_data: meta tags" { + const data = try testStructuredData( + \\Page Title + \\ + \\ + \\ + \\ + ); + try testing.expectEqual("Page Title", findProperty(data.meta, "title").?); + try testing.expectEqual("A test page", findProperty(data.meta, "description").?); + try testing.expectEqual("Test Author", findProperty(data.meta, "author").?); + try testing.expectEqual("test, example", findProperty(data.meta, "keywords").?); + try testing.expectEqual("index, follow", findProperty(data.meta, "robots").?); +} + +test "structured_data: link elements" { + const data = try testStructuredData( + \\ + \\ + \\ + \\ + ); + try testing.expectEqual(3, data.links.len); + try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?); + // stylesheet should be filtered out + try testing.expectEqual(null, findProperty(data.links, "stylesheet")); +} + +test "structured_data: alternate links" { + const data = try testStructuredData( + \\ + \\ + ); + try testing.expectEqual(2, data.alternate.len); + try testing.expectEqual("fr", data.alternate[0].hreflang.?); + try testing.expectEqual("French", data.alternate[0].title.?); + try testing.expectEqual("de", data.alternate[1].hreflang.?); + try testing.expectEqual(null, data.alternate[1].title); +} + +test "structured_data: non-metadata elements ignored" { + const data = try testStructuredData( + \\
Just text
+ \\

More text

+ \\Link + ); + try testing.expectEqual(0, data.json_ld.len); + try testing.expectEqual(0, data.open_graph.len); + try testing.expectEqual(0, data.twitter_card.len); + try testing.expectEqual(0, data.meta.len); + try testing.expectEqual(0, data.links.len); +} + +test "structured_data: charset and http-equiv" { + const data = try testStructuredData( + \\ + \\ + ); + try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?); + try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?); +} + +test "structured_data: mixed content" { + const data = try testStructuredData( + \\My Site + \\ + \\ + \\ + \\ + \\ + ); + try testing.expectEqual(1, data.json_ld.len); + try testing.expectEqual(1, data.open_graph.len); + try testing.expectEqual(1, data.twitter_card.len); + try testing.expectEqual("My Site", findProperty(data.meta, "title").?); + try testing.expectEqual("A page", findProperty(data.meta, "description").?); + try testing.expectEqual(1, data.links.len); +} diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index 5503c356..84ae8417 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -19,15 +19,18 @@ const std = @import("std"); const lp = @import("lightpanda"); const markdown = lp.markdown; +const structured_data = lp.structured_data; const Node = @import("../Node.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { getMarkdown, + getStructuredData, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getMarkdown => return getMarkdown(cmd), + .getStructuredData => return getStructuredData(cmd), } } @@ -54,6 +57,21 @@ fn getMarkdown(cmd: anytype) !void { }, .{}); } +fn getStructuredData(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.NoBrowserContext; + const page = bc.session.currentPage() orelse return error.PageNotLoaded; + + const data = try structured_data.collectStructuredData( + page.document.asNode(), + cmd.arena, + page, + ); + + return cmd.sendResult(.{ + .structuredData = data, + }, .{}); +} + const testing = @import("../testing.zig"); test "cdp.lp: getMarkdown" { var ctx = testing.context(); @@ -70,3 +88,19 @@ test "cdp.lp: getMarkdown" { const result = ctx.client.?.sent.items[0].object.get("result").?.object; try testing.expect(result.get("markdown") != null); } + +test "cdp.lp: getStructuredData" { + var ctx = testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + _ = try bc.session.createPage(); + + try ctx.processMessage(.{ + .id = 1, + .method = "LP.getStructuredData", + }); + + const result = ctx.client.?.sent.items[0].object.get("result").?.object; + try testing.expect(result.get("structuredData") != null); +} diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 26bc23f0..33dad427 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -30,6 +30,7 @@ pub const log = @import("log.zig"); pub const js = @import("browser/js/js.zig"); pub const dump = @import("browser/dump.zig"); pub const markdown = @import("browser/markdown.zig"); +pub const structured_data = @import("browser/structured_data.zig"); pub const mcp = @import("mcp.zig"); pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); From 12c5bcd24fdb4b85b90471f8d7fa0968f79fd85a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Tue, 10 Mar 2026 10:09:53 +0100 Subject: [PATCH 09/10] cdp: reszie the screenshot to 1920x1080 To be consistent w/ layout size returned --- src/cdp/domains/screenshot.png | Bin 13945 -> 16697 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/cdp/domains/screenshot.png b/src/cdp/domains/screenshot.png index 12fbde5ddf2767307d5cbe5ed3533c6b5bafc7b7..cbac5f3425bbc0d70382faf13533ac9216c58a6e 100644 GIT binary patch literal 16697 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 UG+*D=yZ?n(kX4bXkTMSWA6^O?;{X5v literal 13945 zcmbWebxa*^&<2VYhl4vD4({&m2QL(NC{mmP#a$2X?v%E8ad&rjcXxMgf8UoYf869I zce2?`o|&C_X7_4Qu$VmbU3cBR8 zqPq0Q$HzYe%D04$mezJ)`|Rv&d3m{~r|0PC=<@QirKM$kecj*R|MBs0WMpJ~d^|in z{O#@S`uh6o*RMbzP(wqbu&_{8R#r?*3>_UE1Om0Uw^vqH!okD$_4V!U?&9O&y}Y~t z0D#TSO=4nVV`F0)YUuc?jh?(X;Z z_l}N^larI-WqqlssbOJZhlhtZPp_Xpe_mW%{PX9}ueQnR>gvde{kge0*{G_rva-#? z^SztLmeJ+R%*>OMlZAzaq@<*Rh2#4A`Y&I;Of2s{y}m7+-nF*2dU<&j7Zs~av~IdyS$H8(e(otI zzUbJ#fC4fuZXKmgA1o~`tzX=8b8`y|3$I_^L(y_CZXKr19*~7seko|{n3#of&4sdv z2(2H4k~J1cX-H`sdCALhhl1jYkdqQucV9m33i$86`RBbA;}^sHr~dyF4dJ_^Vz|b%wL*W3cYGn<^eV(&zuV?#p2i z&gkUXe+R*&85X5v_-Q#@CzN^i2kpYKm4B9IZwPmaMJE|T$Y4s-PVe*18W&o2rY0X% zfJu{2ll6`nr;fH8=*bW6lbRKudQTtDjp3qpdDoD+O8zYFFlqC74-0H_7n`sv%fX^wIA~%MIOVBzAv9pkvNf*aI&mR1X3Vg>r4x zaKJb|BQnYzr4CURAa<^ymm*T>Bom1sbp%79$)+C4$=_>y3e$GXNTdW;X>uVEZvIBf z+TEnMXm7}fa&Sz?k8$Jxk~7LBB2Su#3nIWK6ms!zY@(tp^Wn!){fb1q6kx4&WCF^` zr>fyLTEfiFaYoDb@cz^6Nci`;aSc+u0|-f(^sdEXH$(~}Q5=U+m{~gH{9ZMMKnQS$ zRh=bS2=1R>BvxthU(@4Ap`+x#`>A+StkMCak(KbYcfT9uwnIW)e)@AUyv{Q(V&9)t z9PI9~ydfCIl+}PH#w-?~ly897 zg>lgKO~OO>8O${JDHR?%O6ppNywI`DpG(u79L09hyI+ugl@%DxoxxNI+)0dBP#ewn z2gn7{p!EMyKG?|t+|d0O2Z6-cg(BDZ9AdQE+`nQP@`EBuC3kQ@^L0G`LTtIbiY0i8 zIrd;D`Z1A_9LtqJ(lpFVJT3Y4n}}h!2X35bJq0lgLC?Lrh=ryYk&V80m=%ngH?9{a z|Bi6nrwcRjo@dnS&5CcM2T>2eJ`OmUx*biUUJ?}W*JyrH8Oj@oUK{QW6HuzU6XBRm zicaAd-jWlxbe+9ae9RL>7m>XO^Q{GU-{nx!e$KQBL(*+yv84s?PXHZi=M4yRfJ(|` z@pq<6vDH^Ldfj=51pGxGao0&8o#Eq$pTALWxvLtdFemqy31|B1;qH-iPD_hx{>T0n z;dSNV8y6lXLM?n&vd&coBo~=W{W~CGD!}!#H|-EwLcWcdx80N6+7e=OZ z-aVNunfQ8-Z#R^aXsG~;&wPl6@UD6;OSZ`0erbRmx&<*)0W`r>IOw|2@wGDID$*4E z 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 Date: Tue, 10 Mar 2026 13:18:25 +0100 Subject: [PATCH 10/10] use std.mem.startsWith, group duplicate property keys into arrays Address review feedback: - replace custom startsWith helper with std.mem.startsWith - writeProperties now groups repeated keys (e.g. multiple og:image) into JSON arrays; single-occurrence keys remain strings - add test for duplicate key serialization --- src/browser/structured_data.zig | 82 +++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig index 4335fb88..9b6e7fbe 100644 --- a/src/browser/structured_data.zig +++ b/src/browser/structured_data.zig @@ -93,11 +93,41 @@ pub const StructuredData = struct { } }; +/// Serializes properties as a JSON object. When a key appears multiple times +/// (e.g. multiple og:image tags), values are grouped into an array. +/// Alternatives considered: always-array values (verbose), or an array of +/// {key, value} pairs (preserves order but less ergonomic for consumers). fn writeProperties(jw: anytype, properties: []const Property) !void { try jw.beginObject(); - for (properties) |prop| { + for (properties, 0..) |prop, i| { + // Skip keys already written by an earlier occurrence. + var already_written = false; + for (properties[0..i]) |prev| { + if (std.mem.eql(u8, prev.key, prop.key)) { + already_written = true; + break; + } + } + if (already_written) continue; + + // Count total occurrences to decide string vs array. + var count: usize = 0; + for (properties) |p| { + if (std.mem.eql(u8, p.key, prop.key)) count += 1; + } + try jw.objectField(prop.key); - try jw.write(prop.value); + if (count == 1) { + try jw.write(prop.value); + } else { + try jw.beginArray(); + for (properties) |p| { + if (std.mem.eql(u8, p.key, prop.key)) { + try jw.write(p.value); + } + } + try jw.endArray(); + } } try jw.endObject(); } @@ -194,16 +224,16 @@ fn collectMeta( // Open Graph: if (el.getAttributeSafe(comptime .wrap("property"))) |property| { - if (startsWith(property, "og:")) { + if (std.mem.startsWith(u8, property, "og:")) { try open_graph.append(arena, .{ .key = property[3..], .value = content }); return; } // Article, profile, etc. are OG sub-namespaces. - if (startsWith(property, "article:") or - startsWith(property, "profile:") or - startsWith(property, "book:") or - startsWith(property, "music:") or - startsWith(property, "video:")) + if (std.mem.startsWith(u8, property, "article:") or + std.mem.startsWith(u8, property, "profile:") or + std.mem.startsWith(u8, property, "book:") or + std.mem.startsWith(u8, property, "music:") or + std.mem.startsWith(u8, property, "video:")) { try open_graph.append(arena, .{ .key = property, .value = content }); return; @@ -212,7 +242,7 @@ fn collectMeta( // Twitter Cards: if (el.getAttributeSafe(comptime .wrap("name"))) |name| { - if (startsWith(name, "twitter:")) { + if (std.mem.startsWith(u8, name, "twitter:")) { try twitter_card.append(arena, .{ .key = name[8..], .value = content }); return; } @@ -283,11 +313,6 @@ fn collectLink( } } -fn startsWith(haystack: []const u8, prefix: []const u8) bool { - if (haystack.len < prefix.len) return false; - return std.mem.eql(u8, haystack[0..prefix.len], prefix); -} - // --- Tests --- const testing = @import("../testing.zig"); @@ -344,6 +369,35 @@ test "structured_data: open graph" { try testing.expectEqual("2026-03-10", findProperty(data.open_graph, "article:published_time").?); } +test "structured_data: open graph duplicate keys" { + const data = try testStructuredData( + \\ + \\ + \\ + \\ + ); + // Duplicate keys are preserved as separate Property entries. + try testing.expectEqual(4, data.open_graph.len); + + // Verify serialization groups duplicates into arrays. + const json = try std.json.Stringify.valueAlloc(testing.allocator, data, .{}); + defer testing.allocator.free(json); + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); + defer parsed.deinit(); + const og = parsed.value.object.get("openGraph").?.object; + // "title" appears once → string. + switch (og.get("title").?) { + .string => {}, + else => return error.TestUnexpectedResult, + } + // "image" appears 3 times → array. + switch (og.get("image").?) { + .array => |arr| try testing.expectEqual(3, arr.items.len), + else => return error.TestUnexpectedResult, + } +} + test "structured_data: twitter card" { const data = try testStructuredData( \\