Merge branch 'main' into semantic-tree

This commit is contained in:
Adrià Arrufat
2026-03-09 18:08:10 +09:00
11 changed files with 291 additions and 12 deletions

View File

@@ -108,7 +108,7 @@ jobs:
run: |
./wpt serve 2> /dev/null & echo $! > WPT.pid
sleep 10s
./wptrunner -lpd-path ./lightpanda -json -concurrency 6 > wpt.json
./wptrunner -lpd-path ./lightpanda -json -concurrency 10 -pool 3 > wpt.json
kill `cat WPT.pid`
- name: write commit

View File

@@ -652,6 +652,10 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url:
.navigation_type = std.meta.activeTag(nt),
};
if (target._queued_navigation) |existing| {
target.arena_pool.release(existing.arena);
}
target._queued_navigation = qn;
return session.scheduleNavigation(target);
}
@@ -2908,9 +2912,12 @@ fn nodeIsReady(self: *Page, comptime from_parser: bool, node: *Node) !void {
}
if (node.is(Element.Html.Script)) |script| {
if ((comptime from_parser == false) and script._src.len == 0) {
// script was added via JavaScript, but without a src, don't try
// to execute it (we'll execute it if/when the src is set)
return;
// Script was added via JavaScript without a src attribute.
// Only skip if it has no inline content either — scripts with
// textContent/text should still execute per spec.
if (node.firstChild() == null) {
return;
}
}
self.scriptAddedCallback(from_parser, script) catch |err| {

View File

@@ -535,6 +535,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
nested_gop.key_ptr.* = owned_specifier;
nested_gop.value_ptr.* = .{};
try script_manager.preloadImport(owned_specifier, url);
} else if (nested_gop.value_ptr.module == null) {
// Entry exists but module failed to compile previously.
// The imported_modules entry may have been consumed, so
// re-preload to ensure waitForImport can find it.
// Key was stored via dupeZ so it has a sentinel in memory.
const key = nested_gop.key_ptr.*;
const key_z: [:0]const u8 = key.ptr[0..key.len :0];
try script_manager.preloadImport(key_z, url);
}
}
}
@@ -683,7 +691,15 @@ fn _resolveModuleCallback(self: *Context, referrer: js.Module, specifier: [:0]co
return local.toLocal(m).handle;
}
var source = try self.script_manager.?.waitForImport(normalized_specifier);
var source = self.script_manager.?.waitForImport(normalized_specifier) catch |err| switch (err) {
error.UnknownModule => blk: {
// Module is in cache but was consumed from imported_modules
// (e.g., by a previous failed resolution). Re-preload and retry.
try self.script_manager.?.preloadImport(normalized_specifier, referrer_path);
break :blk try self.script_manager.?.waitForImport(normalized_specifier);
},
else => return err,
};
defer source.deinit();
var try_catch: js.TryCatch = undefined;

View File

@@ -730,6 +730,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/css/CSSStyleRule.zig"),
@import("../webapi/css/CSSStyleSheet.zig"),
@import("../webapi/css/CSSStyleProperties.zig"),
@import("../webapi/css/FontFace.zig"),
@import("../webapi/css/FontFaceSet.zig"),
@import("../webapi/css/MediaQueryList.zig"),
@import("../webapi/css/StyleSheetList.zig"),

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="constructor_basic">
{
const face = new FontFace("TestFont", "url(test.woff)");
testing.expectTrue(face instanceof FontFace);
}
</script>
<script id="constructor_name">
{
testing.expectEqual('FontFace', FontFace.name);
}
</script>
<script id="family_property">
{
const face = new FontFace("MyFont", "url(font.woff2)");
testing.expectEqual("MyFont", face.family);
}
</script>
<script id="status_is_loaded">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("loaded", face.status);
}
</script>
<script id="loaded_is_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.loaded instanceof Promise);
}
</script>
<script id="load_returns_promise">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectTrue(face.load() instanceof Promise);
}
</script>
<script id="default_descriptors">
{
const face = new FontFace("F", "url(f.woff)");
testing.expectEqual("normal", face.style);
testing.expectEqual("normal", face.weight);
testing.expectEqual("normal", face.stretch);
testing.expectEqual("normal", face.variant);
testing.expectEqual("normal", face.featureSettings);
testing.expectEqual("auto", face.display);
}
</script>
<script id="document_fonts_add">
{
const face = new FontFace("AddedFont", "url(added.woff)");
const result = document.fonts.add(face);
testing.expectTrue(result === document.fonts);
}
</script>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<head></head>
<script src="../../../testing.js"></script>
<script id=textContent_inline>
window.inline_executed = false;
const s1 = document.createElement('script');
s1.textContent = 'window.inline_executed = true;';
document.head.appendChild(s1);
testing.expectTrue(window.inline_executed);
</script>
<script id=text_property_inline>
window.text_executed = false;
const s2 = document.createElement('script');
s2.text = 'window.text_executed = true;';
document.head.appendChild(s2);
testing.expectTrue(window.text_executed);
</script>
<script id=innerHTML_inline>
window.innerHTML_executed = false;
const s3 = document.createElement('script');
s3.innerHTML = 'window.innerHTML_executed = true;';
document.head.appendChild(s3);
testing.expectTrue(window.innerHTML_executed);
</script>
<script id=no_double_execute_inline>
window.inline_counter = 0;
const s4 = document.createElement('script');
s4.textContent = 'window.inline_counter++;';
document.head.appendChild(s4);
document.head.appendChild(s4);
testing.expectEqual(1, window.inline_counter);
</script>
<script id=empty_script_no_execute>
window.empty_ran = false;
const s5 = document.createElement('script');
document.head.appendChild(s5);
testing.expectFalse(window.empty_ran);
</script>
<script id=module_inline>
window.module_executed = false;
const s6 = document.createElement('script');
s6.type = 'module';
s6.textContent = 'window.module_executed = true;';
document.head.appendChild(s6);
testing.eventually(() => {
testing.expectTrue(window.module_executed);
});
</script>

View File

@@ -81,6 +81,17 @@
}
</script>
<script id="is_empty">
{
// Empty :is() and :where() are valid per spec and match nothing
const isEmptyResult = document.querySelectorAll(':is()');
testing.expectEqual(0, isEmptyResult.length);
const whereEmptyResult = document.querySelectorAll(':where()');
testing.expectEqual(0, whereEmptyResult.length);
}
</script>
<div id=escaped class=":popover-open"></div>
<script id="escaped">
{

View File

@@ -12,8 +12,6 @@
// Empty functional pseudo-classes should error
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':is()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':where()'));
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
}
</script>

View File

@@ -0,0 +1,90 @@
// 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 js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Allocator = std.mem.Allocator;
const FontFace = @This();
_arena: Allocator,
_family: []const u8,
pub fn init(family: []const u8, source: []const u8, page: *Page) !*FontFace {
_ = source;
const arena = try page.getArena(.{.debug = "FontFace"});
errdefer page.releaseArena(arena);
const self = try arena.create(FontFace);
self.* = .{
._arena = arena,
._family = try arena.dupe(u8, family),
};
return self;
}
pub fn deinit(self: *FontFace, _: bool, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn getFamily(self: *const FontFace) []const u8 {
return self._family;
}
// load() - resolves immediately; headless browser has no real font loading.
pub fn load(_: *FontFace, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
// loaded - returns an already-resolved Promise.
pub fn getLoaded(_: *FontFace, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FontFace);
pub const Meta = struct {
pub const name = "FontFace";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FontFace.deinit);
};
pub const constructor = bridge.constructor(FontFace.init, .{});
pub const family = bridge.accessor(FontFace.getFamily, null, .{});
pub const status = bridge.property("loaded", .{ .template = false, .readonly = true });
pub const style = bridge.property("normal", .{ .template = false, .readonly = true });
pub const weight = bridge.property("normal", .{ .template = false, .readonly = true });
pub const stretch = bridge.property("normal", .{ .template = false, .readonly = true });
pub const unicodeRange = bridge.property("U+0-10FFFF", .{ .template = false, .readonly = true });
pub const variant = bridge.property("normal", .{ .template = false, .readonly = true });
pub const featureSettings = bridge.property("normal", .{ .template = false, .readonly = true });
pub const display = bridge.property("auto", .{ .template = false, .readonly = true });
pub const loaded = bridge.accessor(FontFace.getLoaded, null, .{});
pub const load = bridge.function(FontFace.load, .{});
};
const testing = @import("../../../testing.zig");
test "WebApi: FontFace" {
try testing.htmlRunner("css/font_face.html", .{});
}

View File

@@ -1,14 +1,45 @@
// 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 js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const FontFace = @import("FontFace.zig");
const Allocator = std.mem.Allocator;
const FontFaceSet = @This();
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
_pad: bool = false,
_arena: Allocator,
pub fn init(page: *Page) !*FontFaceSet {
return page._factory.create(FontFaceSet{});
const arena = try page.getArena(.{.debug = "FontFaceSet"});
errdefer page.releaseArena(arena);
const self = try arena.create(FontFaceSet);
self.* = .{
._arena = arena,
};
return self;
}
pub fn deinit(self: *FontFaceSet, _: bool, page: *Page) void {
page.releaseArena(self._arena);
}
// FontFaceSet.ready - returns an already-resolved Promise.
@@ -29,6 +60,11 @@ pub fn load(_: *FontFaceSet, font: []const u8, page: *Page) !js.Promise {
return page.js.local.?.resolvePromise({});
}
// add(fontFace) - no-op; headless browser does not track loaded fonts.
pub fn add(self: *FontFaceSet, _: *FontFace) *FontFaceSet {
return self;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FontFaceSet);
@@ -36,6 +72,8 @@ pub const JsApi = struct {
pub const name = "FontFaceSet";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
pub const weak = true;
pub const finalizer = bridge.finalizer(FontFaceSet.deinit);
};
pub const size = bridge.property(0, .{ .template = false, .readonly = true });
@@ -43,6 +81,7 @@ pub const JsApi = struct {
pub const ready = bridge.accessor(FontFaceSet.getReady, null, .{});
pub const check = bridge.function(FontFaceSet.check, .{});
pub const load = bridge.function(FontFaceSet.load, .{});
pub const add = bridge.function(FontFaceSet.add, .{});
};
const testing = @import("../../../testing.zig");

View File

@@ -487,7 +487,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
if (self.peek() != ')') return error.InvalidPseudoClass;
self.input = self.input[1..];
if (selectors.items.len == 0) return error.InvalidPseudoClass;
// Empty :is() is valid per spec - matches nothing
return .{ .is = selectors.items };
}
@@ -514,7 +514,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
if (self.peek() != ')') return error.InvalidPseudoClass;
self.input = self.input[1..];
if (selectors.items.len == 0) return error.InvalidPseudoClass;
// Empty :where() is valid per spec - matches nothing
return .{ .where = selectors.items };
}