mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-22 12:44:43 +00:00
Merge branch 'main' into semantic-tree
This commit is contained in:
2
.github/workflows/wpt.yml
vendored
2
.github/workflows/wpt.yml
vendored
@@ -108,7 +108,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
||||||
sleep 10s
|
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`
|
kill `cat WPT.pid`
|
||||||
|
|
||||||
- name: write commit
|
- name: write commit
|
||||||
|
|||||||
@@ -652,6 +652,10 @@ fn scheduleNavigationWithArena(originator: *Page, arena: Allocator, request_url:
|
|||||||
.navigation_type = std.meta.activeTag(nt),
|
.navigation_type = std.meta.activeTag(nt),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (target._queued_navigation) |existing| {
|
||||||
|
target.arena_pool.release(existing.arena);
|
||||||
|
}
|
||||||
|
|
||||||
target._queued_navigation = qn;
|
target._queued_navigation = qn;
|
||||||
return session.scheduleNavigation(target);
|
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 (node.is(Element.Html.Script)) |script| {
|
||||||
if ((comptime from_parser == false) and script._src.len == 0) {
|
if ((comptime from_parser == false) and script._src.len == 0) {
|
||||||
// script was added via JavaScript, but without a src, don't try
|
// Script was added via JavaScript without a src attribute.
|
||||||
// to execute it (we'll execute it if/when the src is set)
|
// Only skip if it has no inline content either — scripts with
|
||||||
return;
|
// textContent/text should still execute per spec.
|
||||||
|
if (node.firstChild() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.scriptAddedCallback(from_parser, script) catch |err| {
|
self.scriptAddedCallback(from_parser, script) catch |err| {
|
||||||
|
|||||||
@@ -535,6 +535,14 @@ fn postCompileModule(self: *Context, mod: js.Module, url: [:0]const u8, local: *
|
|||||||
nested_gop.key_ptr.* = owned_specifier;
|
nested_gop.key_ptr.* = owned_specifier;
|
||||||
nested_gop.value_ptr.* = .{};
|
nested_gop.value_ptr.* = .{};
|
||||||
try script_manager.preloadImport(owned_specifier, url);
|
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;
|
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();
|
defer source.deinit();
|
||||||
|
|
||||||
var try_catch: js.TryCatch = undefined;
|
var try_catch: js.TryCatch = undefined;
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ pub const JsApis = flattenTypes(&.{
|
|||||||
@import("../webapi/css/CSSStyleRule.zig"),
|
@import("../webapi/css/CSSStyleRule.zig"),
|
||||||
@import("../webapi/css/CSSStyleSheet.zig"),
|
@import("../webapi/css/CSSStyleSheet.zig"),
|
||||||
@import("../webapi/css/CSSStyleProperties.zig"),
|
@import("../webapi/css/CSSStyleProperties.zig"),
|
||||||
|
@import("../webapi/css/FontFace.zig"),
|
||||||
@import("../webapi/css/FontFaceSet.zig"),
|
@import("../webapi/css/FontFaceSet.zig"),
|
||||||
@import("../webapi/css/MediaQueryList.zig"),
|
@import("../webapi/css/MediaQueryList.zig"),
|
||||||
@import("../webapi/css/StyleSheetList.zig"),
|
@import("../webapi/css/StyleSheetList.zig"),
|
||||||
|
|||||||
63
src/browser/tests/css/font_face.html
Normal file
63
src/browser/tests/css/font_face.html
Normal 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>
|
||||||
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal file
54
src/browser/tests/element/html/script/dynamic_inline.html
Normal 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>
|
||||||
@@ -81,6 +81,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
<div id=escaped class=":popover-open"></div>
|
||||||
<script id="escaped">
|
<script id="escaped">
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@
|
|||||||
// Empty functional pseudo-classes should error
|
// Empty functional pseudo-classes should error
|
||||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
|
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':has()'));
|
||||||
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':not()'));
|
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()'));
|
testing.expectError("Error: InvalidPseudoClass", () => container.querySelector(':lang()'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
90
src/browser/webapi/css/FontFace.zig
Normal file
90
src/browser/webapi/css/FontFace.zig
Normal 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", .{});
|
||||||
|
}
|
||||||
@@ -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 std = @import("std");
|
||||||
const js = @import("../../js/js.zig");
|
const js = @import("../../js/js.zig");
|
||||||
const Page = @import("../../Page.zig");
|
const Page = @import("../../Page.zig");
|
||||||
|
const FontFace = @import("FontFace.zig");
|
||||||
|
|
||||||
|
const Allocator = std.mem.Allocator;
|
||||||
|
|
||||||
const FontFaceSet = @This();
|
const FontFaceSet = @This();
|
||||||
|
|
||||||
// Padding to avoid zero-size struct, which causes identity_map pointer collisions.
|
_arena: Allocator,
|
||||||
_pad: bool = false,
|
|
||||||
|
|
||||||
pub fn init(page: *Page) !*FontFaceSet {
|
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.
|
// 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({});
|
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 JsApi = struct {
|
||||||
pub const bridge = js.Bridge(FontFaceSet);
|
pub const bridge = js.Bridge(FontFaceSet);
|
||||||
|
|
||||||
@@ -36,6 +72,8 @@ pub const JsApi = struct {
|
|||||||
pub const name = "FontFaceSet";
|
pub const name = "FontFaceSet";
|
||||||
pub const prototype_chain = bridge.prototypeChain();
|
pub const prototype_chain = bridge.prototypeChain();
|
||||||
pub var class_id: bridge.ClassId = undefined;
|
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 });
|
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 ready = bridge.accessor(FontFaceSet.getReady, null, .{});
|
||||||
pub const check = bridge.function(FontFaceSet.check, .{});
|
pub const check = bridge.function(FontFaceSet.check, .{});
|
||||||
pub const load = bridge.function(FontFaceSet.load, .{});
|
pub const load = bridge.function(FontFaceSet.load, .{});
|
||||||
|
pub const add = bridge.function(FontFaceSet.add, .{});
|
||||||
};
|
};
|
||||||
|
|
||||||
const testing = @import("../../../testing.zig");
|
const testing = @import("../../../testing.zig");
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
|
|||||||
if (self.peek() != ')') return error.InvalidPseudoClass;
|
if (self.peek() != ')') return error.InvalidPseudoClass;
|
||||||
self.input = self.input[1..];
|
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 };
|
return .{ .is = selectors.items };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +514,7 @@ fn pseudoClass(self: *Parser, arena: Allocator, page: *Page) !Selector.PseudoCla
|
|||||||
if (self.peek() != ')') return error.InvalidPseudoClass;
|
if (self.peek() != ')') return error.InvalidPseudoClass;
|
||||||
self.input = self.input[1..];
|
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 };
|
return .{ .where = selectors.items };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user