Merge branch 'main' into semantic-tree

This commit is contained in:
Adrià Arrufat
2026-03-10 21:48:49 +09:00
27 changed files with 1858 additions and 148 deletions

View File

@@ -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 { fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
const ShadowRoot = @import("webapi/ShadowRoot.zig"); const ShadowRoot = @import("webapi/ShadowRoot.zig");

View File

@@ -791,24 +791,19 @@ fn _documentIsComplete(self: *Page) !void {
try self.dispatchLoad(); try self.dispatchLoad();
// Dispatch window.load event. // Dispatch window.load event.
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); const window_target = self.window.asEventTarget();
// This event is weird, it's dispatched directly on the window, but if (self._event_manager.hasDirectListeners(window_target, "load", self.window._on_load)) {
// with the document as the target. const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
event._target = self.document.asEventTarget(); // This event is weird, it's dispatched directly on the window, but
try self._event_manager.dispatchDirect( // with the document as the target.
self.window.asEventTarget(), event._target = self.document.asEventTarget();
event, try self._event_manager.dispatchDirect(window_target, event, self.window._on_load, .{ .inject_target = false, .context = "page load" });
self.window._on_load, }
.{ .inject_target = false, .context = "page load" },
);
const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent(); if (self._event_manager.hasDirectListeners(window_target, "pageshow", self.window._on_pageshow)) {
try self._event_manager.dispatchDirect( const pageshow_event = (try PageTransitionEvent.initTrusted(comptime .wrap("pageshow"), .{}, self)).asEvent();
self.window.asEventTarget(), try self._event_manager.dispatchDirect(window_target, pageshow_event, self.window._on_pageshow, .{ .context = "page show" });
pageshow_event, }
self.window._on_pageshow,
.{ .context = "page show" },
);
self.notifyParentLoadComplete(); self.notifyParentLoadComplete();
} }

View File

@@ -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 query_end = if (query_start) |_| (fragment_start orelse url.len) else path_end;
const path_to_encode = url[path_start..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 encoded_query = if (query_start) |qs| blk: {
const query_to_encode = url[qs + 1 .. query_end]; 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; break :blk encoded;
} else null; } else null;
const encoded_fragment = if (fragment_start) |fs| blk: { const encoded_fragment = if (fragment_start) |fs| blk: {
const fragment_to_encode = url[fs + 1 ..]; 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; break :blk encoded;
} else null; } 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]; 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 // Check if encoding is needed
var needs_encoding = false; var needs_encoding = false;
for (segment) |c| { for (segment) |c| {
if (shouldPercentEncode(c, is_path)) { if (shouldPercentEncode(c, encode_set)) {
needs_encoding = true; needs_encoding = true;
break; 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}); try buf.writer(allocator).print("%{X:0>2}", .{c});
} else { } else {
try buf.append(allocator, c); try buf.append(allocator, c);
@@ -245,16 +247,17 @@ fn percentEncodeSegment(allocator: Allocator, segment: []const u8, comptime is_p
return buf.items; return buf.items;
} }
fn shouldPercentEncode(c: u8, comptime is_path: bool) bool { fn shouldPercentEncode(c: u8, comptime encode_set: EncodeSet) bool {
return switch (c) { return switch (c) {
// Unreserved characters (RFC 3986) // Unreserved characters (RFC 3986)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false, 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => false,
// sub-delims allowed in both path and query // sub-delims allowed in path/query but some must be encoded in userinfo
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => false, '!', '$', '&', '\'', '(', ')', '*', '+', ',' => false,
// Separators allowed in both path and query ';', '=' => encode_set == .userinfo,
'/', ':', '@' => false, // Separators: userinfo must encode these
// Query-specific: '?' is allowed in queries but not in paths '/', ':', '@' => encode_set == .userinfo,
'?' => comptime is_path, // '?' is allowed in queries but not in paths or userinfo
'?' => encode_set != .query,
// Everything else needs encoding (including space) // Everything else needs encoding (including space)
else => true, else => true,
}; };
@@ -514,7 +517,7 @@ pub fn setHost(current: [:0]const u8, value: []const u8, allocator: Allocator) !
const search = getSearch(current); const search = getSearch(current);
const hash = getHash(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 colon_pos = std.mem.lastIndexOfScalar(u8, value, ':');
const clean_host = if (colon_pos) |pos| blk: { const clean_host = if (colon_pos) |pos| blk: {
const port_str = value[pos + 1 ..]; 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[0..pos];
} }
break :blk value; 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); 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 { pub fn setPort(current: [:0]const u8, value: ?[]const u8, allocator: Allocator) ![:0]const u8 {
const hostname = getHostname(current); const hostname = getHostname(current);
const protocol = getProtocol(current); const protocol = getProtocol(current);
const pathname = getPathname(current);
const search = getSearch(current);
const hash = getHash(current);
// Handle null or default ports // Handle null or default ports
const new_host = if (value) |port_str| blk: { 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 }); break :blk try std.fmt.allocPrint(allocator, "{s}:{s}", .{ hostname, port_str });
} else hostname; } 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 { 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); 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 { pub fn concatQueryString(arena: Allocator, url: []const u8, query_string: []const u8) ![:0]const u8 {
if (query_string.len == 0) { if (query_string.len == 0) {
return arena.dupeZ(u8, url); return arena.dupeZ(u8, url);

View File

@@ -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) { if (last_parameter_type_info == .pointer and last_parameter_type_info.pointer.size == .slice) {
const slice_type = last_parameter_type_info.pointer.child; const slice_type = last_parameter_type_info.pointer.child;
const corresponding_js_value = info.getArg(@intCast(last_js_parameter), local); 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; is_variadic = true;
if (js_parameter_count == 0) { if (js_parameter_count == 0) {
@field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{}; @field(args, tupleFieldName(params_to_map.len + offset - 1)) = &.{};

View File

@@ -0,0 +1,489 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// 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 <https://www.gnu.org/licenses/>.
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 <html> 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 <head>.
// JSON-LD can appear in <body> 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: <meta 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: <meta property="og:...">
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: <meta name="twitter:...">
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(
\\<script type="application/ld+json">
\\{"@context":"https://schema.org","@type":"Article","headline":"Test"}
\\</script>
);
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(
\\<script type="application/ld+json">{"@type":"Organization"}</script>
\\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script>
\\<script type="text/javascript">var x = 1;</script>
);
try testing.expectEqual(2, data.json_ld.len);
}
test "structured_data: open graph" {
const data = try testStructuredData(
\\<meta property="og:title" content="My Page">
\\<meta property="og:description" content="A description">
\\<meta property="og:image" content="https://example.com/img.jpg">
\\<meta property="og:url" content="https://example.com">
\\<meta property="og:type" content="article">
\\<meta property="article:published_time" content="2026-03-10">
);
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(
\\<meta property="og:title" content="My Page">
\\<meta property="og:image" content="https://example.com/img1.jpg">
\\<meta property="og:image" content="https://example.com/img2.jpg">
\\<meta property="og:image" content="https://example.com/img3.jpg">
);
// 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(
\\<meta name="twitter:card" content="summary_large_image">
\\<meta name="twitter:site" content="@example">
\\<meta name="twitter:title" content="My Page">
);
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(
\\<title>Page Title</title>
\\<meta name="description" content="A test page">
\\<meta name="author" content="Test Author">
\\<meta name="keywords" content="test, example">
\\<meta name="robots" content="index, follow">
);
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(
\\<link rel="canonical" href="https://example.com/page">
\\<link rel="icon" href="/favicon.ico">
\\<link rel="manifest" href="/manifest.json">
\\<link rel="stylesheet" href="/style.css">
);
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(
\\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French">
\\<link rel="alternate" href="https://example.com/de" hreflang="de">
);
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(
\\<div>Just text</div>
\\<p>More text</p>
\\<a href="/link">Link</a>
);
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(
\\<meta charset="utf-8">
\\<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
);
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(
\\<title>My Site</title>
\\<meta property="og:title" content="OG Title">
\\<meta name="twitter:card" content="summary">
\\<meta name="description" content="A page">
\\<link rel="canonical" href="https://example.com">
\\<script type="application/ld+json">{"@type":"WebSite"}</script>
);
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);
}

View File

@@ -98,6 +98,64 @@
} }
</script> </script>
<script id=mime_parsing>
// MIME types are lowercased
{
const blob = new Blob([], { type: "TEXT/HTML" });
testing.expectEqual("text/html", blob.type);
}
{
const blob = new Blob([], { type: "Application/JSON" });
testing.expectEqual("application/json", blob.type);
}
// MIME with parameters - lowercased
{
const blob = new Blob([], { type: "text/html; charset=UTF-8" });
testing.expectEqual("text/html; charset=utf-8", blob.type);
}
// Any ASCII string is accepted and lowercased (no MIME structure validation)
{
const blob = new Blob([], { type: "invalid" });
testing.expectEqual("invalid", blob.type);
}
{
const blob = new Blob([], { type: "/" });
testing.expectEqual("/", blob.type);
}
// Non-ASCII characters cause empty string (chars outside U+0020-U+007E)
{
const blob = new Blob([], { type: "ý/x" });
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "text/plàin" });
testing.expectEqual("", blob.type);
}
// Control characters cause empty string
{
const blob = new Blob([], { type: "text/html\x00" });
testing.expectEqual("", blob.type);
}
// Empty type stays empty
{
const blob = new Blob([]);
testing.expectEqual("", blob.type);
}
{
const blob = new Blob([], { type: "" });
testing.expectEqual("", blob.type);
}
</script>
<script id=slice> <script id=slice>
{ {
const parts = ["la", "symphonie", "des", "éclairs"]; const parts = ["la", "symphonie", "des", "éclairs"];

View File

@@ -275,3 +275,109 @@
testing.expectEqual('red', div.style.getPropertyValue('color')); testing.expectEqual('red', div.style.getPropertyValue('color'));
} }
</script> </script>
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
{
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
const div = document.createElement('div');
div.style.width = '0';
testing.expectEqual('0px', div.style.width);
div.style.margin = '0';
testing.expectEqual('0px', div.style.margin);
div.style.padding = '0';
testing.expectEqual('0px', div.style.padding);
div.style.top = '0';
testing.expectEqual('0px', div.style.top);
// Non-length properties should not be affected
div.style.opacity = '0';
testing.expectEqual('0', div.style.opacity);
div.style.zIndex = '0';
testing.expectEqual('0', div.style.zIndex);
}
</script>
<script id="CSSStyleDeclaration_normalize_first_baseline">
{
// "first baseline" should serialize canonically as "baseline"
const div = document.createElement('div');
div.style.alignItems = 'first baseline';
testing.expectEqual('baseline', div.style.alignItems);
div.style.alignContent = 'first baseline';
testing.expectEqual('baseline', div.style.alignContent);
// "last baseline" should remain unchanged
div.style.alignItems = 'last baseline';
testing.expectEqual('last baseline', div.style.alignItems);
}
</script>
<script id="CSSStyleDeclaration_normalize_duplicate_values">
{
// For 2-value shorthand properties, "X X" should collapse to "X"
const div = document.createElement('div');
div.style.placeContent = 'center center';
testing.expectEqual('center', div.style.placeContent);
div.style.placeContent = 'start start';
testing.expectEqual('start', div.style.placeContent);
div.style.gap = '10px 10px';
testing.expectEqual('10px', div.style.gap);
// Different values should not collapse
div.style.placeContent = 'center start';
testing.expectEqual('center start', div.style.placeContent);
div.style.gap = '10px 20px';
testing.expectEqual('10px 20px', div.style.gap);
}
</script>
<script id="CSSStyleDeclaration_normalize_anchor_size">
{
// anchor-size() should serialize with dashed ident (anchor name) before size keyword
const div = document.createElement('div');
// Already canonical order - should stay the same
div.style.width = 'anchor-size(--foo width)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// Non-canonical order - should be reordered
div.style.width = 'anchor-size(width --foo)';
testing.expectEqual('anchor-size(--foo width)', div.style.width);
// With fallback value
div.style.width = 'anchor-size(height --bar, 100px)';
testing.expectEqual('anchor-size(--bar height, 100px)', div.style.width);
// Different size keywords
div.style.width = 'anchor-size(block --baz)';
testing.expectEqual('anchor-size(--baz block)', div.style.width);
div.style.width = 'anchor-size(inline --qux)';
testing.expectEqual('anchor-size(--qux inline)', div.style.width);
div.style.width = 'anchor-size(self-block --test)';
testing.expectEqual('anchor-size(--test self-block)', div.style.width);
div.style.width = 'anchor-size(self-inline --test)';
testing.expectEqual('anchor-size(--test self-inline)', div.style.width);
// Without anchor name (implicit default anchor)
div.style.width = 'anchor-size(width)';
testing.expectEqual('anchor-size(width)', div.style.width);
// Nested anchor-size in fallback
div.style.width = 'anchor-size(width --foo, anchor-size(height --bar))';
testing.expectEqual('anchor-size(--foo width, anchor-size(--bar height))', div.style.width);
}
</script>

View File

@@ -397,3 +397,25 @@
} }
} }
</script> </script>
<script id=getElementsByTagName-xml>
{
const parser = new DOMParser();
const doc = parser.parseFromString('<layout><row><col>A</col><col>B</col></row></layout>', 'text/xml');
// Test getElementsByTagName on document
const rows = doc.getElementsByTagName('row');
testing.expectEqual(1, rows.length);
// Test getElementsByTagName on element
const row = rows[0];
const cols = row.getElementsByTagName('col');
testing.expectEqual(2, cols.length);
testing.expectEqual('A', cols[0].textContent);
testing.expectEqual('B', cols[1].textContent);
// Test getElementsByTagName('*') on element
const allElements = row.getElementsByTagName('*');
testing.expectEqual(2, allElements.length);
}
</script>

View File

@@ -137,3 +137,79 @@
testing.expectEqual('PROPFIND', req.method); testing.expectEqual('PROPFIND', req.method);
} }
</script> </script>
<script id=body_methods>
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'Hello, World!',
headers: { 'Content-Type': 'text/plain' }
});
const text = await req.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: '{"name": "test"}',
headers: { 'Content-Type': 'application/json' }
});
const json = await req.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'binary data',
headers: { 'Content-Type': 'application/octet-stream' }
});
const buffer = await req.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'blob content',
headers: { 'Content-Type': 'text/plain' }
});
const blob = await req.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const req = new Request('https://example.com/api', {
method: 'POST',
body: 'bytes'
});
const bytes = await req.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const req1 = new Request('https://example.com/api', {
method: 'POST',
body: 'test body',
headers: { 'X-Custom': 'value' }
});
const req2 = req1.clone();
testing.expectEqual(req1.url, req2.url);
testing.expectEqual(req1.method, req2.method);
testing.expectEqual('value', req2.headers.get('X-Custom'));
}
</script>

View File

@@ -2,51 +2,113 @@
<script src="../testing.js"></script> <script src="../testing.js"></script>
<script id=response> <script id=response>
// let response = new Response("Hello, World!"); {
// testing.expectEqual(200, response.status); let response = new Response("Hello, World!");
// testing.expectEqual("", response.statusText); testing.expectEqual(200, response.status);
// testing.expectEqual(true, response.ok); testing.expectEqual("", response.statusText);
// testing.expectEqual("", response.url); testing.expectEqual(true, response.ok);
// testing.expectEqual(false, response.redirected); testing.expectEqual("", response.url);
testing.expectEqual(false, response.redirected);
}
let response2 = new Response("Error occurred", { {
status: 404, let response2 = new Response("Error occurred", {
statusText: "Not Found", status: 404,
headers: { statusText: "Not Found",
"Content-Type": "text/plain", headers: {
"X-Custom": "test-value", "Content-Type": "text/plain",
"Cache-Control": "no-cache" "X-Custom": "test-value",
} "Cache-Control": "no-cache"
}); }
testing.expectEqual(true, true);
// testing.expectEqual(404, response2.status);
// testing.expectEqual("Not Found", response2.statusText);
// testing.expectEqual(false, response2.ok);
// testing.expectEqual("text/plain", response2.headers);
// testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
// let response3 = new Response("Created", { status: 201, statusText: "Created" });
// testing.expectEqual("basic", response3.type);
// testing.expectEqual(201, response3.status);
// testing.expectEqual("Created", response3.statusText);
// testing.expectEqual(true, response3.ok);
// let nullResponse = new Response(null);
// testing.expectEqual(200, nullResponse.status);
// testing.expectEqual("", nullResponse.statusText);
// let emptyResponse = new Response("");
// testing.expectEqual(200, emptyResponse.status);
</script>
<!-- <script id=json>
testing.async(async () => {
const json = await new Promise((resolve) => {
let response = new Response('[]');
response.json().then(resolve)
}); });
testing.expectEqual([], json); testing.expectEqual(404, response2.status);
testing.expectEqual("Not Found", response2.statusText);
testing.expectEqual(false, response2.ok);
testing.expectEqual("test-value", response2.headers.get("X-Custom"));
testing.expectEqual("no-cache", response2.headers.get("cache-control"));
}
{
let response3 = new Response("Created", { status: 201, statusText: "Created" });
testing.expectEqual("basic", response3.type);
testing.expectEqual(201, response3.status);
testing.expectEqual("Created", response3.statusText);
testing.expectEqual(true, response3.ok);
}
{
let nullResponse = new Response(null);
testing.expectEqual(200, nullResponse.status);
testing.expectEqual("", nullResponse.statusText);
}
{
let emptyResponse = new Response("");
testing.expectEqual(200, emptyResponse.status);
}
</script>
<script id=body_methods>
testing.async(async () => {
const response = new Response('Hello, World!');
const text = await response.text();
testing.expectEqual('Hello, World!', text);
});
testing.async(async () => {
const response = new Response('{"name": "test"}');
const json = await response.json();
testing.expectEqual('test', json.name);
});
testing.async(async () => {
const response = new Response('binary data');
const buffer = await response.arrayBuffer();
testing.expectEqual(true, buffer instanceof ArrayBuffer);
testing.expectEqual(11, buffer.byteLength);
});
testing.async(async () => {
const response = new Response('blob content', {
headers: { 'Content-Type': 'text/plain' }
});
const blob = await response.blob();
testing.expectEqual(true, blob instanceof Blob);
testing.expectEqual(12, blob.size);
testing.expectEqual('text/plain', blob.type);
});
testing.async(async () => {
const response = new Response('bytes');
const bytes = await response.bytes();
testing.expectEqual(true, bytes instanceof Uint8Array);
testing.expectEqual(5, bytes.length);
});
</script>
<script id=clone>
{
const response1 = new Response('test body', {
status: 201,
statusText: 'Created',
headers: { 'X-Custom': 'value' }
});
const response2 = response1.clone();
testing.expectEqual(response1.status, response2.status);
testing.expectEqual(response1.statusText, response2.statusText);
testing.expectEqual('value', response2.headers.get('X-Custom'));
}
testing.async(async () => {
const response1 = new Response('cloned body');
const response2 = response1.clone();
const text1 = await response1.text();
const text2 = await response2.text();
testing.expectEqual('cloned body', text1);
testing.expectEqual('cloned body', text2);
}); });
</script> </script>
-->

View File

@@ -218,6 +218,106 @@
testing.expectEqual('', url.password); 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'); const url = new URL('http://user:pass@example.com:8080/path?query=1#hash');
testing.expectEqual('http:', url.protocol); testing.expectEqual('http:', url.protocol);
@@ -437,9 +537,9 @@
{ {
const url = new URL('https://example.com:8080/path'); const url = new URL('https://example.com:8080/path');
url.host = 'newhost.com'; 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('newhost.com', url.hostname);
testing.expectEqual('', url.port); testing.expectEqual('8080', url.port);
} }
{ {

View File

@@ -76,13 +76,11 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, page: *Page) !void {
} }
// Dispatch abort event // Dispatch abort event
const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page); const target = self.asEventTarget();
try page._event_manager.dispatchDirect( if (page._event_manager.hasDirectListeners(target, "abort", self._on_abort)) {
self.asEventTarget(), const event = try Event.initTrusted(comptime .wrap("abort"), .{}, page);
event, try page._event_manager.dispatchDirect(target, event, self._on_abort, .{ .context = "abort signal" });
self._on_abort, }
.{ .context = "abort signal" },
);
} }
// Static method to create an already-aborted signal // Static method to create an already-aborted signal

View File

@@ -21,6 +21,7 @@ const Writer = std.Io.Writer;
const js = @import("../js/js.zig"); const js = @import("../js/js.zig");
const Page = @import("../Page.zig"); const Page = @import("../Page.zig");
const Mime = @import("../Mime.zig");
/// https://w3c.github.io/FileAPI/#blob-section /// https://w3c.github.io/FileAPI/#blob-section
/// https://developer.mozilla.org/en-US/docs/Web/API/Blob /// https://developer.mozilla.org/en-US/docs/Web/API/Blob
@@ -50,21 +51,50 @@ const InitOptions = struct {
endings: []const u8 = "transparent", endings: []const u8 = "transparent",
}; };
/// Creates a new Blob. /// Creates a new Blob (JS constructor).
pub fn init( pub fn init(
maybe_blob_parts: ?[]const []const u8, maybe_blob_parts: ?[]const []const u8,
maybe_options: ?InitOptions, maybe_options: ?InitOptions,
page: *Page, 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 { ) !*Blob {
const options: InitOptions = maybe_options orelse .{}; const options: InitOptions = maybe_options orelse .{};
// Setup MIME; This can be any string according to my observations.
const mime: []const u8 = blk: { const mime: []const u8 = blk: {
const t = options.type; const t = options.type;
if (t.len == 0) { if (t.len == 0) {
break :blk ""; 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: { const data = blk: {

View File

@@ -138,6 +138,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.screen => writer.writeAll("<Screen>"), .screen => writer.writeAll("<Screen>"),
.screen_orientation => writer.writeAll("<ScreenOrientation>"), .screen_orientation => writer.writeAll("<ScreenOrientation>"),
.visual_viewport => writer.writeAll("<VisualViewport>"), .visual_viewport => writer.writeAll("<VisualViewport>"),
.file_reader => writer.writeAll("<FileReader>"),
}; };
} }

View File

@@ -79,13 +79,11 @@ fn goInner(delta: i32, page: *Page) !void {
if (entry._url) |url| { if (entry._url) |url| {
if (try page.isSameOrigin(url)) { if (try page.isSameOrigin(url)) {
const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent(); const target = page.window.asEventTarget();
try page._event_manager.dispatchDirect( if (page._event_manager.hasDirectListeners(target, "popstate", page.window._on_popstate)) {
page.window.asEventTarget(), const event = (try PopStateEvent.initTrusted(comptime .wrap("popstate"), .{ .state = entry._state.value }, page)).asEvent();
event, try page._event_manager.dispatchDirect(target, event, page.window._on_popstate, .{ .context = "Pop State" });
page.window._on_popstate, }
.{ .context = "Pop State" },
);
} }
} }

View File

@@ -122,23 +122,21 @@ const PostMessageCallback = struct {
return null; return null;
} }
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{ const target = self.port.asEventTarget();
.data = self.message, if (page._event_manager.hasDirectListeners(target, "message", self.port._on_message)) {
.origin = "", const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.source = null, .data = self.message,
}, page) catch |err| { .origin = "",
log.err(.dom, "MessagePort.postMessage", .{ .err = err }); .source = null,
return null; }, page) catch |err| {
}).asEvent(); log.err(.dom, "MessagePort.postMessage", .{ .err = err });
return null;
}).asEvent();
page._event_manager.dispatchDirect( page._event_manager.dispatchDirect(target, event, self.port._on_message, .{ .context = "MessagePort message" }) catch |err| {
self.port.asEventTarget(), log.err(.dom, "MessagePort.postMessage", .{ .err = err });
event, };
self.port._on_message, }
.{ .context = "MessagePort message" },
) catch |err| {
log.err(.dom, "MessagePort.postMessage", .{ .err = err });
};
return null; return null;
} }

View File

@@ -66,10 +66,20 @@ pub fn getUsername(self: *const URL) []const u8 {
return U.getUsername(self._raw); 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 { pub fn getPassword(self: *const URL) []const u8 {
return U.getPassword(self._raw); 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 { pub fn getPathname(self: *const URL) []const u8 {
return U.getPathname(self._raw); return U.getPathname(self._raw);
} }
@@ -272,8 +282,8 @@ pub const JsApi = struct {
pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{}); pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{});
pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{}); pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{});
pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{}); pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{});
pub const username = bridge.accessor(URL.getUsername, null, .{}); pub const username = bridge.accessor(URL.getUsername, URL.setUsername, .{});
pub const password = bridge.accessor(URL.getPassword, null, .{}); pub const password = bridge.accessor(URL.getPassword, URL.setPassword, .{});
pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{}); pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{});
pub const host = bridge.accessor(URL.getHost, URL.setHost, .{}); pub const host = bridge.accessor(URL.getHost, URL.setHost, .{});
pub const port = bridge.accessor(URL.getPort, URL.setPort, .{}); pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});

View File

@@ -551,17 +551,14 @@ pub fn unhandledPromiseRejection(self: *Window, rejection: js.PromiseRejection,
}); });
} }
const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{ const target = self.asEventTarget();
.reason = if (rejection.reason()) |r| try r.temp() else null, if (page._event_manager.hasDirectListeners(target, "unhandledrejection", self._on_unhandled_rejection)) {
.promise = try rejection.promise().temp(), const event = (try @import("event/PromiseRejectionEvent.zig").init("unhandledrejection", .{
}, page)).asEvent(); .reason = if (rejection.reason()) |r| try r.temp() else null,
.promise = try rejection.promise().temp(),
try page._event_manager.dispatchDirect( }, page)).asEvent();
self.asEventTarget(), try page._event_manager.dispatchDirect(target, event, self._on_unhandled_rejection, .{ .context = "window.unhandledrejection" });
event, }
self._on_unhandled_rejection,
.{ .inject_target = true, .context = "window.unhandledrejection" },
);
} }
const ScheduleOpts = struct { const ScheduleOpts = struct {

View File

@@ -219,7 +219,14 @@ pub fn NodeLive(comptime mode: Mode) type {
switch (mode) { switch (mode) {
.tag => { .tag => {
const el = node.is(Element) orelse return false; 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 => { .tag_name => {
// If we're in `tag_name` mode, then the tag_name isn't // If we're in `tag_name` mode, then the tag_name isn't

View File

@@ -26,6 +26,8 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const Allocator = std.mem.Allocator;
const CSSStyleDeclaration = @This(); const CSSStyleDeclaration = @This();
_element: ?*Element = null, _element: ?*Element = null,
@@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const normalized = normalizePropertyName(property_name, &page.buf); 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 // Find existing property
if (self.findProperty(normalized)) |existing| { 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; existing._important = important;
return; return;
} }
@@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const prop = try page._factory.create(Property{ const prop = try page._factory.create(Property{
._node = .{}, ._node = .{},
._name = try String.init(page.arena, normalized, .{}), ._name = try String.init(page.arena, normalized, .{}),
._value = try String.init(page.arena, value, .{}), ._value = try String.init(page.arena, normalized_value, .{}),
._important = important, ._important = important,
}); });
self._properties.append(&prop._node); self._properties.append(&prop._node);
@@ -227,6 +232,351 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 {
return std.ascii.lowerString(buf, name); 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 <length> or <length-percentage> 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 { fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 {
if (std.mem.eql(u8, normalized_name, "visibility")) { if (std.mem.eql(u8, normalized_name, "visibility")) {
return "visible"; return "visible";

View File

@@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con
} }
fn isKnownCSSProperty(dash_case: []const u8) bool { 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(.{ const known_properties = std.StaticStringMap(void).initComptime(.{
// Colors & backgrounds
.{ "color", {} }, .{ "color", {} },
.{ "background", {} },
.{ "background-color", {} }, .{ "background-color", {} },
.{ "background-image", {} },
.{ "background-position", {} },
.{ "background-repeat", {} },
.{ "background-size", {} },
.{ "background-attachment", {} },
.{ "background-clip", {} },
.{ "background-origin", {} },
// Typography
.{ "font", {} },
.{ "font-family", {} },
.{ "font-size", {} }, .{ "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-top", {} },
.{ "margin-right", {} },
.{ "margin-bottom", {} }, .{ "margin-bottom", {} },
.{ "margin-left", {} }, .{ "margin-left", {} },
.{ "margin-right", {} }, .{ "margin-block", {} },
.{ "margin-block-start", {} },
.{ "margin-block-end", {} },
.{ "margin-inline", {} },
.{ "margin-inline-start", {} },
.{ "margin-inline-end", {} },
.{ "padding", {} },
.{ "padding-top", {} }, .{ "padding-top", {} },
.{ "padding-right", {} },
.{ "padding-bottom", {} }, .{ "padding-bottom", {} },
.{ "padding-left", {} }, .{ "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-left-radius", {} },
.{ "border-top-right-radius", {} }, .{ "border-top-right-radius", {} },
.{ "border-bottom-left-radius", {} }, .{ "border-bottom-left-radius", {} },
.{ "border-bottom-right-radius", {} }, .{ "border-bottom-right-radius", {} },
.{ "float", {} }, .{ "border-collapse", {} },
.{ "z-index", {} }, .{ "border-spacing", {} },
// Sizing
.{ "width", {} }, .{ "width", {} },
.{ "height", {} }, .{ "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", {} }, .{ "display", {} },
.{ "visibility", {} }, .{ "visibility", {} },
.{ "opacity", {} }, .{ "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", {} },
.{ "transform-origin", {} },
.{ "transform-style", {} },
.{ "perspective", {} },
.{ "perspective-origin", {} },
.{ "transition", {} }, .{ "transition", {} },
.{ "position", {} }, .{ "transition-property", {} },
.{ "top", {} }, .{ "transition-duration", {} },
.{ "bottom", {} }, .{ "transition-timing-function", {} },
.{ "left", {} }, .{ "transition-delay", {} },
.{ "right", {} }, .{ "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); return known_properties.has(dash_case);

View File

@@ -24,6 +24,7 @@ const Http = @import("../../../http/Http.zig");
const URL = @import("../URL.zig"); const URL = @import("../URL.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const Blob = @import("../Blob.zig");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Request = @This(); const Request = @This();
@@ -153,6 +154,55 @@ pub fn getHeaders(self: *Request, page: *Page) !*Headers {
return 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 JsApi = struct {
pub const bridge = js.Bridge(Request); pub const bridge = js.Bridge(Request);
@@ -168,6 +218,12 @@ pub const JsApi = struct {
pub const headers = bridge.accessor(Request.getHeaders, null, .{}); pub const headers = bridge.accessor(Request.getHeaders, null, .{});
pub const cache = bridge.accessor(Request.getCache, null, .{}); pub const cache = bridge.accessor(Request.getCache, null, .{});
pub const credentials = bridge.accessor(Request.getCredentials, 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"); const testing = @import("../../../testing.zig");

View File

@@ -23,6 +23,7 @@ const Http = @import("../../../http/Http.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Headers = @import("Headers.zig"); const Headers = @import("Headers.zig");
const ReadableStream = @import("../streams/ReadableStream.zig"); const ReadableStream = @import("../streams/ReadableStream.zig");
const Blob = @import("../Blob.zig");
const Allocator = std.mem.Allocator; 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 "" }); 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 JsApi = struct {
pub const bridge = js.Bridge(Response); pub const bridge = js.Bridge(Response);
@@ -170,6 +212,9 @@ pub const JsApi = struct {
pub const url = bridge.accessor(Response.getURL, null, .{}); pub const url = bridge.accessor(Response.getURL, null, .{});
pub const redirected = bridge.accessor(Response.isRedirected, null, .{}); pub const redirected = bridge.accessor(Response.isRedirected, null, .{});
pub const arrayBuffer = bridge.function(Response.arrayBuffer, .{}); 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"); const testing = @import("../../../testing.zig");

View File

@@ -508,13 +508,11 @@ fn stateChanged(self: *XMLHttpRequest, state: ReadyState, page: *Page) !void {
self._ready_state = state; self._ready_state = state;
const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page); const target = self.asEventTarget();
try page._event_manager.dispatchDirect( if (page._event_manager.hasDirectListeners(target, "readystatechange", self._on_ready_state_change)) {
self.asEventTarget(), const event = try Event.initTrusted(.wrap("readystatechange"), .{}, page);
event, try page._event_manager.dispatchDirect(target, event, self._on_ready_state_change, .{ .context = "XHR state change" });
self._on_ready_state_change, }
.{ .context = "XHR state change" },
);
} }
fn parseMethod(method: []const u8) !Http.Method { fn parseMethod(method: []const u8) !Http.Method {

View File

@@ -22,6 +22,7 @@ const log = @import("../../log.zig");
const markdown = lp.markdown; const markdown = lp.markdown;
const SemanticTree = lp.SemanticTree; const SemanticTree = lp.SemanticTree;
const interactive = lp.interactive; const interactive = lp.interactive;
const structured_data = lp.structured_data;
const Node = @import("../Node.zig"); const Node = @import("../Node.zig");
const DOMNode = @import("../../browser/webapi/Node.zig"); const DOMNode = @import("../../browser/webapi/Node.zig");
@@ -30,12 +31,14 @@ pub fn processMessage(cmd: anytype) !void {
getMarkdown, getMarkdown,
getSemanticTree, getSemanticTree,
getInteractiveElements, getInteractiveElements,
getStructuredData,
}, cmd.input.action) orelse return error.UnknownMethod; }, cmd.input.action) orelse return error.UnknownMethod;
switch (action) { switch (action) {
.getMarkdown => return getMarkdown(cmd), .getMarkdown => return getMarkdown(cmd),
.getSemanticTree => return getSemanticTree(cmd), .getSemanticTree => return getSemanticTree(cmd),
.getInteractiveElements => return getInteractiveElements(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"); const testing = @import("../testing.zig");
test "cdp.lp: getMarkdown" { test "cdp.lp: getMarkdown" {
var ctx = testing.context(); var ctx = testing.context();
@@ -161,3 +179,19 @@ test "cdp.lp: getInteractiveElements" {
try testing.expect(result.get("elements") != null); try testing.expect(result.get("elements") != null);
try testing.expect(result.get("nodeIds") != 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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -34,6 +34,7 @@ pub const markdown = @import("browser/markdown.zig");
pub const SemanticTree = @import("SemanticTree.zig"); pub const SemanticTree = @import("SemanticTree.zig");
pub const CDPNode = @import("cdp/Node.zig"); pub const CDPNode = @import("cdp/Node.zig");
pub const interactive = @import("browser/interactive.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 mcp = @import("mcp.zig");
pub const build_config = @import("build_config"); pub const build_config = @import("build_config");
pub const crash_handler = @import("crash_handler.zig"); pub const crash_handler = @import("crash_handler.zig");