diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig index 52485256..ccbb1336 100644 --- a/src/browser/EventManager.zig +++ b/src/browser/EventManager.zig @@ -373,6 +373,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/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/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)) = &.{}; diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig new file mode 100644 index 00000000..9b6e7fbe --- /dev/null +++ b/src/browser/structured_data.zig @@ -0,0 +1,489 @@ +// 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(); + } +}; + +/// 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, 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); + 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(); +} + +/// 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 (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 (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; + } + } + + // Twitter Cards: + if (el.getAttributeSafe(comptime .wrap("name"))) |name| { + if (std.mem.startsWith(u8, 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; + } + } +} + +// --- 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: 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( + \\ + \\ + \\ + ); + 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/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/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/tests/net/request.html b/src/browser/tests/net/request.html index 2a88e83e..b4497e14 100644 --- a/src/browser/tests/net/request.html +++ b/src/browser/tests/net/request.html @@ -137,3 +137,79 @@ testing.expectEqual('PROPFIND', req.method); } + + + + 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/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/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/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/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/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, .{}); 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/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 diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index a8eb92e4..adc8982a 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,351 @@ 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; + } + } + + // 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; + 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); 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"); 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 { diff --git a/src/cdp/domains/lp.zig b/src/cdp/domains/lp.zig index b3ab07d3..3cb876e8 100644 --- a/src/cdp/domains/lp.zig +++ b/src/cdp/domains/lp.zig @@ -22,6 +22,7 @@ const log = @import("../../log.zig"); const markdown = lp.markdown; const SemanticTree = lp.SemanticTree; const interactive = lp.interactive; +const structured_data = lp.structured_data; const Node = @import("../Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig"); @@ -30,12 +31,14 @@ pub fn processMessage(cmd: anytype) !void { getMarkdown, getSemanticTree, getInteractiveElements, + getStructuredData, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { .getMarkdown => return getMarkdown(cmd), .getSemanticTree => return getSemanticTree(cmd), .getInteractiveElements => return getInteractiveElements(cmd), + .getStructuredData => return getStructuredData(cmd), } } @@ -128,6 +131,21 @@ fn getInteractiveElements(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(); @@ -161,3 +179,19 @@ test "cdp.lp: getInteractiveElements" { try testing.expect(result.get("elements") != null); try testing.expect(result.get("nodeIds") != 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/cdp/domains/screenshot.png b/src/cdp/domains/screenshot.png index 12fbde5d..cbac5f34 100644 Binary files a/src/cdp/domains/screenshot.png and b/src/cdp/domains/screenshot.png differ diff --git a/src/lightpanda.zig b/src/lightpanda.zig index b3366622..be9d727f 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -34,6 +34,7 @@ pub const markdown = @import("browser/markdown.zig"); pub const SemanticTree = @import("SemanticTree.zig"); pub const CDPNode = @import("cdp/Node.zig"); pub const interactive = @import("browser/interactive.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");