mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-23 05:04:42 +00:00
Merge branch 'main' into semantic-tree
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) = &.{};
|
||||
|
||||
489
src/browser/structured_data.zig
Normal file
489
src/browser/structured_data.zig
Normal 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);
|
||||
}
|
||||
@@ -98,6 +98,64 @@
|
||||
}
|
||||
</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>
|
||||
{
|
||||
const parts = ["la", "symphonie", "des", "éclairs"];
|
||||
|
||||
@@ -275,3 +275,109 @@
|
||||
testing.expectEqual('red', div.style.getPropertyValue('color'));
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -397,3 +397,25 @@
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -137,3 +137,79 @@
|
||||
testing.expectEqual('PROPFIND', req.method);
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -2,51 +2,113 @@
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=response>
|
||||
// let response = new Response("Hello, World!");
|
||||
// testing.expectEqual(200, response.status);
|
||||
// testing.expectEqual("", response.statusText);
|
||||
// testing.expectEqual(true, response.ok);
|
||||
// testing.expectEqual("", response.url);
|
||||
// testing.expectEqual(false, response.redirected);
|
||||
{
|
||||
let response = new Response("Hello, World!");
|
||||
testing.expectEqual(200, response.status);
|
||||
testing.expectEqual("", response.statusText);
|
||||
testing.expectEqual(true, response.ok);
|
||||
testing.expectEqual("", response.url);
|
||||
testing.expectEqual(false, response.redirected);
|
||||
}
|
||||
|
||||
let response2 = new Response("Error occurred", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"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)
|
||||
{
|
||||
let response2 = new Response("Error occurred", {
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
"X-Custom": "test-value",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
});
|
||||
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>
|
||||
-->
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -138,6 +138,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
|
||||
.screen => writer.writeAll("<Screen>"),
|
||||
.screen_orientation => writer.writeAll("<ScreenOrientation>"),
|
||||
.visual_viewport => writer.writeAll("<VisualViewport>"),
|
||||
.file_reader => writer.writeAll("<FileReader>"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, .{});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <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 {
|
||||
if (std.mem.eql(u8, normalized_name, "visibility")) {
|
||||
return "visible";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 16 KiB |
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user