From be0a808f01732e069433c9a5e4575d35f0455520 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 25 Nov 2025 19:50:53 +0800 Subject: [PATCH] Add HTMLSlotElement, PerformanceObserver and Script get/set type --- src/browser/Page.zig | 22 +- src/browser/ScriptManager.zig | 57 ++- src/browser/js/bridge.zig | 2 + src/browser/tests/element/html/slot.html | 384 +++++++++++++++ .../custom_element_composition.html | 456 ++++++++++++++++++ src/browser/tests/window/window.html | 1 - src/browser/webapi/Element.zig | 4 + src/browser/webapi/Performance.zig | 66 +++ src/browser/webapi/PerformanceObserver.zig | 67 +++ src/browser/webapi/element/Html.zig | 3 + src/browser/webapi/element/html/Script.zig | 9 + src/browser/webapi/element/html/Slot.zig | 151 ++++++ 12 files changed, 1192 insertions(+), 30 deletions(-) create mode 100644 src/browser/tests/element/html/slot.html create mode 100644 src/browser/tests/integration/custom_element_composition.html create mode 100644 src/browser/webapi/PerformanceObserver.zig create mode 100644 src/browser/webapi/element/html/Slot.zig diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 28c1fcac..80500316 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -1024,6 +1024,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ else => {}, }, 4 => switch (@as(u32, @bitCast(name[0..4].*))) { + asUint("span") => return self.createHtmlElementT( + Element.Html.Generic, + namespace, + attribute_iterator, + .{ ._proto = undefined, ._tag_name = String.init(undefined, "span", .{}) catch unreachable, ._tag = .span }, + ), asUint("meta") => return self.createHtmlElementT( Element.Html.Meta, namespace, @@ -1036,6 +1042,12 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined }, ), + asUint("slot") => return self.createHtmlElementT( + Element.Html.Slot, + namespace, + attribute_iterator, + .{ ._proto = undefined }, + ), asUint("html") => return self.createHtmlElementT( Element.Html.Html, namespace, @@ -1066,12 +1078,6 @@ pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_ attribute_iterator, .{ ._proto = undefined, ._tag_name = String.init(undefined, "main", .{}) catch unreachable, ._tag = .main }, ), - asUint("span") => return self.createHtmlElementT( - Element.Html.Generic, - namespace, - attribute_iterator, - .{ ._proto = undefined, ._tag_name = String.init(undefined, "span", .{}) catch unreachable, ._tag = .span }, - ), else => {}, }, 5 => switch (@as(u40, @bitCast(name[0..5].*))) { @@ -1787,3 +1793,7 @@ const testing = @import("../testing.zig"); test "WebApi: Page" { try testing.htmlRunner("page", .{}); } + +test "WebApi: Integration" { + try testing.htmlRunner("integration", .{}); +} diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index 632be5f2..0d421db1 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -249,11 +249,14 @@ pub fn addFromElement(self: *ScriptManager, script_element: *Element.Html.Script .error_callback = Script.errorCallback, }); - log.debug(.http, "script queue", .{ - .ctx = ctx, - .url = remote_url.?, - .stack = page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .ctx = ctx, + .url = remote_url.?, + .element = element, + .stack = page.js.stackTrace() catch "???", + }); + } } if (script.mode != .normal) { @@ -326,12 +329,14 @@ pub fn preloadImport(self: *ScriptManager, url: [:0]const u8, referrer: []const var headers = try self.client.newHeaders(); try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers); - log.debug(.http, "script queue", .{ - .url = url, - .ctx = "module", - .referrer = referrer, - .stack = self.page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .url = url, + .ctx = "module", + .referrer = referrer, + .stack = self.page.js.stackTrace() catch "???", + }); + } try self.client.request(.{ .url = url, @@ -403,12 +408,14 @@ pub fn getAsyncImport(self: *ScriptManager, url: [:0]const u8, cb: ImportAsync.C var headers = try self.client.newHeaders(); try self.page.requestCookie(.{}).headersForRequest(self.page.arena, url, &headers); - log.debug(.http, "script queue", .{ - .url = url, - .ctx = "dynamic module", - .referrer = referrer, - .stack = self.page.js.stackTrace() catch "???", - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script queue", .{ + .url = url, + .ctx = "dynamic module", + .referrer = referrer, + .stack = self.page.js.stackTrace() catch "???", + }); + } // It's possible, but unlikely, for client.request to immediately finish // a request, thus calling our callback. We generally don't want a call @@ -617,11 +624,13 @@ const Script = struct { return; } - log.debug(.http, "script header", .{ - .req = transfer, - .status = header.status, - .content_type = header.contentType(), - }); + if (comptime IS_DEBUG) { + log.debug(.http, "script header", .{ + .req = transfer, + .status = header.status, + .content_type = header.contentType(), + }); + } // If this isn't true, then we'll likely leak memory. If you don't // set `CURLOPT_SUPPRESS_CONNECT_HEADERS` and CONNECT to a proxy, this @@ -649,7 +658,9 @@ const Script = struct { fn doneCallback(ctx: *anyopaque) !void { const self: *Script = @ptrCast(@alignCast(ctx)); self.complete = true; - log.debug(.http, "script fetch complete", .{ .req = self.url }); + if (comptime IS_DEBUG) { + log.debug(.http, "script fetch complete", .{ .req = self.url }); + } const manager = self.manager; if (self.mode == .async or self.mode == .import_async) { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 7ac4f0ad..b93c3ec0 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -538,6 +538,7 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/element/html/Paragraph.zig"), @import("../webapi/element/html/Script.zig"), @import("../webapi/element/html/Select.zig"), + @import("../webapi/element/html/Slot.zig"), @import("../webapi/element/html/Style.zig"), @import("../webapi/element/html/Template.zig"), @import("../webapi/element/html/TextArea.zig"), @@ -574,4 +575,5 @@ pub const JsApis = flattenTypes(&.{ @import("../webapi/Blob.zig"), @import("../webapi/File.zig"), @import("../webapi/Screen.zig"), + @import("../webapi/PerformanceObserver.zig"), }); diff --git a/src/browser/tests/element/html/slot.html b/src/browser/tests/element/html/slot.html new file mode 100644 index 00000000..af2b0808 --- /dev/null +++ b/src/browser/tests/element/html/slot.html @@ -0,0 +1,384 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/integration/custom_element_composition.html b/src/browser/tests/integration/custom_element_composition.html new file mode 100644 index 00000000..3559d9f8 --- /dev/null +++ b/src/browser/tests/integration/custom_element_composition.html @@ -0,0 +1,456 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html index c378c130..9cd74b37 100644 --- a/src/browser/tests/window/window.html +++ b/src/browser/tests/window/window.html @@ -102,4 +102,3 @@ testing.expectEqual(24, screen.pixelDepth); testing.expectEqual(screen, window.screen); - diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 023da510..fb9b927e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -148,6 +148,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 { .p => "p", .script => "script", .select => "select", + .slot => "slot", .style => "style", .template => "template", .text_area => "textarea", @@ -192,6 +193,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 { .p => "P", .script => "SCRIPT", .select => "SELECT", + .slot => "SLOT", .style => "STYLE", .template => "TEMPLATE", .text_area => "TEXTAREA", @@ -790,6 +792,7 @@ pub fn getTag(self: *const Element) Tag { .generic => |g| g._tag, .script => .script, .select => .select, + .slot => .slot, .option => .option, .template => .template, .text_area => .textarea, @@ -855,6 +858,7 @@ pub const Tag = enum { rect, script, select, + slot, span, strong, style, diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 3ac87871..60b972a3 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -1,6 +1,13 @@ const js = @import("../js/js.zig"); const datetime = @import("../../datetime.zig"); +pub fn registerTypes() []const type { + return &.{ + Performance, + Entry, + }; +} + const Performance = @This(); _time_origin: u64, @@ -34,6 +41,65 @@ pub const JsApi = struct { pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{}); }; +pub const Entry = struct { + _duration: f64 = 0.0, + _entry_type: Type, + _name: []const u8, + _start_time: f64 = 0.0, + + const Type = enum { + element, + event, + first_input, + largest_contentful_paint, + layout_shift, + long_animation_frame, + longtask, + mark, + measure, + navigation, + paint, + resource, + taskattribution, + visibility_state, + }; + + pub fn getDuration(self: *const Entry) f64 { + return self._duration; + } + + pub fn getEntryType(self: *const Entry) []const u8 { + return switch (self._entry_type) { + .first_input => "first-input", + .largest_contentful_paint => "largest-contentful-paint", + .layout_shift => "layout-shift", + .long_animation_frame => "long-animation-frame", + .visibility_state => "visibility-state", + else => |t| @tagName(t), + }; + } + + pub fn getName(self: *const Entry) []const u8 { + return self._name; + } + + pub fn getStartTime(self: *const Entry) f64 { + return self._start_time; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(Entry); + + pub const Meta = struct { + pub const name = "PerformanceEntry"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + pub const duration = bridge.accessor(Entry.getDuration, null, .{}); + pub const entryType = bridge.accessor(Entry.getEntryType, null, .{}); + }; +}; + const testing = @import("../../testing.zig"); test "WebApi: Performance" { try testing.htmlRunner("performance.html", .{}); diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig new file mode 100644 index 00000000..08bc2733 --- /dev/null +++ b/src/browser/webapi/PerformanceObserver.zig @@ -0,0 +1,67 @@ +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const js = @import("../js/js.zig"); + +const Entry = @import("Performance.zig").Entry; + +// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver +const PerformanceObserver = @This(); + +pub fn init(callback: js.Function) PerformanceObserver { + _ = callback; + return .{}; +} + +const ObserverOptions = struct { + buffered: ?bool = null, + durationThreshold: ?f64 = null, + entryTypes: ?[]const []const u8 = null, + type: ?[]const u8 = null, +}; + +pub fn observe(self: *const PerformanceObserver, opts_: ?ObserverOptions) void { + _ = self; + _ = opts_; + return; +} + +pub fn disconnect(self: *PerformanceObserver) void { + _ = self; +} + +pub fn takeRecords(_: *const PerformanceObserver) []const Entry { + return &.{}; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(PerformanceObserver); + + pub const Meta = struct { + pub const name = "PerformanceObserver"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + pub const empty_with_no_proto = true; + }; + + pub const constructor = bridge.constructor(PerformanceObserver.init, .{}); + + pub const observe = bridge.function(PerformanceObserver.observe, .{}); + pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); + pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); +}; diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 475c1f21..4468b553 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -51,6 +51,7 @@ pub const Template = @import("html/Template.zig"); pub const TextArea = @import("html/TextArea.zig"); pub const Paragraph = @import("html/Paragraph.zig"); pub const Select = @import("html/Select.zig"); +pub const Slot = @import("html/Slot.zig"); pub const Option = @import("html/Option.zig"); pub const IFrame = @import("html/IFrame.zig"); @@ -90,6 +91,7 @@ pub const Type = union(enum) { p: Paragraph, script: *Script, select: Select, + slot: Slot, style: Style, template: *Template, text_area: *TextArea, @@ -131,6 +133,7 @@ pub fn className(self: *const HtmlElement) []const u8 { .generic => "[object HTMLElement]", .script => "[object HtmlScriptElement]", .select => "[object HTMLSelectElement]", + .slot => "[object HTMLSlotElement]", .template => "[object HTMLTemplateElement]", .option => "[object HTMLOptionElement]", .text_area => "[object HtmlTextAreaElement]", diff --git a/src/browser/webapi/element/html/Script.zig b/src/browser/webapi/element/html/Script.zig index c12038a6..e1f55988 100644 --- a/src/browser/webapi/element/html/Script.zig +++ b/src/browser/webapi/element/html/Script.zig @@ -57,6 +57,14 @@ pub fn setSrc(self: *Script, src: []const u8, page: *Page) !void { } } +pub fn getType(self: *const Script) []const u8 { + return self.asConstElement().getAttributeSafe("type") orelse ""; +} + +pub fn setType(self: *Script, value: []const u8, page: *Page) !void { + return self.asElement().setAttributeSafe("type", value, page); +} + pub fn getOnLoad(self: *const Script) ?js.Function { return self._on_load; } @@ -95,6 +103,7 @@ pub const JsApi = struct { }; pub const src = bridge.accessor(Script.getSrc, Script.setSrc, .{}); + pub const @"type" = bridge.accessor(Script.getType, Script.setType, .{}); pub const onload = bridge.accessor(Script.getOnLoad, Script.setOnLoad, .{}); pub const onerorr = bridge.accessor(Script.getOnError, Script.setOnError, .{}); pub const noModule = bridge.accessor(Script.getNoModule, null, .{}); diff --git a/src/browser/webapi/element/html/Slot.zig b/src/browser/webapi/element/html/Slot.zig new file mode 100644 index 00000000..1089ad56 --- /dev/null +++ b/src/browser/webapi/element/html/Slot.zig @@ -0,0 +1,151 @@ +const std = @import("std"); + +const log = @import("../../../../log.zig"); +const js = @import("../../../js/js.zig"); +const Page = @import("../../../Page.zig"); +const Node = @import("../../Node.zig"); +const Element = @import("../../Element.zig"); +const HtmlElement = @import("../Html.zig"); +const ShadowRoot = @import("../../ShadowRoot.zig"); + +const Slot = @This(); + +_proto: *HtmlElement, + +pub fn asElement(self: *Slot) *Element { + return self._proto._proto; +} + +pub fn asConstElement(self: *const Slot) *const Element { + return self._proto._proto; +} + +pub fn asNode(self: *Slot) *Node { + return self.asElement().asNode(); +} + +pub fn getName(self: *const Slot) []const u8 { + return self.asConstElement().getAttributeSafe("name") orelse ""; +} + +pub fn setName(self: *Slot, name: []const u8, page: *Page) !void { + try self.asElement().setAttributeSafe("name", name, page); +} + +const AssignedNodesOptions = struct { + flatten: bool = false, +}; + +pub fn assignedNodes(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Node { + const opts = opts_ orelse AssignedNodesOptions{}; + var nodes: std.ArrayList(*Node) = .empty; + try self.collectAssignedNodes(false, &nodes, opts, page); + return nodes.items; +} + +pub fn assignedElements(self: *Slot, opts_: ?AssignedNodesOptions, page: *Page) ![]const *Element { + const opts = opts_ orelse AssignedNodesOptions{}; + var elements: std.ArrayList(*Element) = .empty; + try self.collectAssignedNodes(true, &elements, opts, page); + return elements.items; +} + +fn CollectionType(comptime elements: bool) type { + return if (elements) *std.ArrayList(*Element) else *std.ArrayList(*Node); +} + +fn collectAssignedNodes(self: *Slot, comptime elements: bool, coll: CollectionType(elements), opts: AssignedNodesOptions, page: *Page) !void { + // Find the shadow root this slot belongs to + const shadow_root = self.findShadowRoot() orelse return; + + const slot_name = self.getName(); + const allocator = page.call_arena; + + const host = shadow_root.getHost(); + var it = host.asNode().childrenIterator(); + while (it.next()) |child| { + if (!isAssignedToSlot(child, slot_name)) { + continue; + } + + if (opts.flatten) { + if (child.is(Slot)) |child_slot| { + // Only flatten if the child slot is actually in a shadow tree + if (child_slot.findShadowRoot()) |_| { + try child_slot.collectAssignedNodes(elements, coll, opts, page); + continue; + } + // Otherwise, treat it as a regular element and fall through + } + } + + if (comptime elements) { + if (child.is(Element)) |el| { + try coll.append(allocator, el); + } + } else { + try coll.append(allocator, child); + } + } +} + +pub fn assign(self: *Slot, nodes: []const *Node) void { + // Imperative slot assignment API + // This would require storing manually assigned nodes + // For now, this is a placeholder for the API + _ = self; + _ = nodes; + + // let's see if this is ever actually used + log.warn(.not_implemented, "Slot.assign", .{ }); +} + +fn findShadowRoot(self: *Slot) ?*ShadowRoot { + // Walk up the parent chain to find the shadow root + var parent = self.asNode()._parent; + while (parent) |p| { + if (p.is(ShadowRoot)) |shadow_root| { + return shadow_root; + } + parent = p._parent; + } + return null; +} + +fn isAssignedToSlot(node: *Node, slot_name: []const u8) bool { + // Check if a node should be assigned to a slot with the given name + if (node.is(Element)) |element| { + // Get the slot attribute from the element + const node_slot = element.getAttributeSafe("slot") orelse ""; + + // Match if: + // - Both are empty (default slot) + // - They match exactly + return std.mem.eql(u8, node_slot, slot_name); + } + + // Text nodes, comments, etc. are only assigned to the default slot + // (when they have no preceding/following element siblings with slot attributes) + // For simplicity, text nodes go to default slot if slot_name is empty + return slot_name.len == 0; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(Slot); + + pub const Meta = struct { + pub const name = "HTMLSlotElement"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const name = bridge.accessor(Slot.getName, Slot.setName, .{}); + pub const assignedNodes = bridge.function(Slot.assignedNodes, .{}); + pub const assignedElements = bridge.function(Slot.assignedElements, .{}); + pub const assign = bridge.function(Slot.assign, .{}); +}; + +const testing = @import("../../../../testing.zig"); +test "WebApi: HTMLSlotElement" { + try testing.htmlRunner("element/html/slot.html", .{}); +}