58 Commits

Author SHA1 Message Date
Karl Seguin
060afcd459 Merge pull request #1313 from lightpanda-io/nikneym/xml-parsing
Support XML parsing
2026-01-08 08:42:21 +08:00
Karl Seguin
5d1522a61f Don't dispatch to listeners added during dispatching
Use the last current listener as a sentinel, so that any listener added during
dispatching can be skipped.
2026-01-08 08:39:49 +08:00
Karl Seguin
b1b54afc56 Merge pull request #1335 from lightpanda-io/build
Embed v8 snapshot in builds
2026-01-08 06:54:38 +08:00
Pierre Tachoire
2abc490732 ci: support build on tag push 2026-01-07 21:11:20 +01:00
Pierre Tachoire
d4807df2e9 add v8 snapshot instructions into the README 2026-01-07 17:22:50 +01:00
Pierre Tachoire
d5f4ca15cc add v8 snapshot in build processes 2026-01-07 17:22:49 +01:00
Pierre Tachoire
e642c85ebd use ReleaseFast build 2026-01-07 17:22:49 +01:00
Halil Durak
7ea0cdba36 update DomParser test 2026-01-07 14:37:44 +03:00
Halil Durak
612b3a26b7 allow other XML MIMEs in parseFromString 2026-01-07 14:37:44 +03:00
Halil Durak
56d89895a8 initial XML parsing support in DOMParser 2026-01-07 14:37:43 +03:00
Karl Seguin
21d502b81f Merge pull request #1326 from lightpanda-io/wp/mrdimidim/use-css-tokenizer
Use css tokenizer for parsing style attrs
2026-01-07 18:09:06 +08:00
Karl Seguin
dd3de6efea Merge pull request #1327 from lightpanda-io/zigdom-cdata-length
Fix `dom/nodes/CharacterData-appendData` WPT
2026-01-07 18:04:05 +08:00
Karl Seguin
d934fe6d4e Merge pull request #1330 from lightpanda-io/zigdom-element-get-by-class-name-fix
fix `dom/nodes/Element-getElementsByClassName` wpt
2026-01-07 18:02:50 +08:00
Karl Seguin
dab6345885 dispatch events with proper this 2026-01-07 17:57:34 +08:00
Karl Seguin
39874137d6 Merge pull request #1333 from lightpanda-io/attribute_removeNamedItem
add attribute.removeNamedItem
2026-01-07 17:47:33 +08:00
Karl Seguin
89f215c3ee Merge pull request #1332 from lightpanda-io/getElementById
getElementById duplicate-id handling
2026-01-07 17:47:19 +08:00
Karl Seguin
408d3f0a53 Track owning documents for nodes which aren't the default document
Track this in a lookup on the page, to avoid having to store a pointer for
_every_ node, given that most nodes _are_ owned by the document.

This helps us ensure nodes can be properly adopted.
2026-01-07 17:46:09 +08:00
Karl Seguin
a010684ce9 Add deprecated Node constants
Remove toString where the [new] auto-generated toString symbol works.

Reject node mutation on attributes.
2026-01-07 17:36:26 +08:00
Karl Seguin
a4a98da4a4 add attribute.removeNamedItem 2026-01-07 16:51:12 +08:00
Karl Seguin
6f30d459d5 getElementById duplicate-id handling
If 2 elements have the same id then,
1 - The first in document-order has to be retrieved. We were ordering by
    insertion order.

2 - When the element is removed, then document.getElementById should return the
    next element with that id in document-order. We were returning null
2026-01-07 15:49:17 +08:00
Muki Kiboigo
71f27a55e1 fix duping of string for getElementsByClassName 2026-01-06 23:07:32 -08:00
Karl Seguin
c92903aae5 Merge pull request #1328 from lightpanda-io/set_outerHTML
add outerHTML setter
2026-01-07 15:04:14 +08:00
Karl Seguin
518e0aa07a add outerHTML setter 2026-01-07 14:49:30 +08:00
Nikolay Govorov
b908b0bf8a Used css tokenizer for parse html attributes 2026-01-07 05:46:18 +00:00
Muki Kiboigo
f9fa5be324 count utf8 codepoints for CData getLength 2026-01-06 21:29:15 -08:00
Karl Seguin
8ec6bb1577 Merge pull request #1322 from lightpanda-io/input_clone
Add 'clone' callback to build, implement for Input
2026-01-07 13:08:20 +08:00
Karl Seguin
70f8c53703 add deprecated properties to Event and improve initEvent 2026-01-07 12:05:11 +08:00
Karl Seguin
6d5a984413 Merge pull request #1323 from lightpanda-io/document_title
Improve document.title getter
2026-01-07 10:42:03 +08:00
Karl Seguin
5fa8fbc6f8 Merge pull request #1324 from lightpanda-io/elements_by_name_nodelist
getElementsByName now returns a NodeList rather than an HTMLCollection
2026-01-07 10:41:54 +08:00
Karl Seguin
7050d5fc68 Merge pull request #1325 from lightpanda-io/tokenlist_treewalker
support element.relList and improve TreeWalker
2026-01-07 10:41:42 +08:00
Karl Seguin
6af9d12f71 support element.relList and improve TreeWalker 2026-01-07 10:34:47 +08:00
Karl Seguin
a54e1db784 getElementsByName now returns a NodeList rather than an HTMLCollection
Auto-implement a toString accessor for any type that has a JsApi.Meta.name
2026-01-07 09:17:51 +08:00
Karl Seguin
2319b0fda5 Improve document.title getter
Collapse whitespace and find the first title, no matter where it is.
2026-01-07 07:52:20 +08:00
Karl Seguin
6864a22721 fix datetime-local input type 2026-01-07 07:39:42 +08:00
Karl Seguin
c9d0e2097d add input indeterminate accessor 2026-01-07 07:35:24 +08:00
Karl Seguin
d8f7eb3f24 Add 'clone' callback to build, implement for Input 2026-01-07 07:29:43 +08:00
Karl Seguin
90ee919f45 Merge pull request #1321 from lightpanda-io/event-init
set _time_stamp in the Event factory
2026-01-07 07:14:34 +08:00
Karl Seguin
ddc6431720 Merge pull request #1316 from lightpanda-io/reject_non_new_constructor
Reject constructors called as function (i.e. without 'new')
2026-01-07 07:14:13 +08:00
Pierre Tachoire
2ea6557fb7 add initEvent into Factory
and remove default value for Event._time_stamp
2026-01-06 15:30:13 +01:00
Karl Seguin
15358c1928 Improve Range, adding missing functions and more validation 2026-01-06 20:27:16 +08:00
Karl Seguin
d65025b3cb Merge pull request #1320 from lightpanda-io/fix-replaceChildren
remove children from previous parent
2026-01-06 19:07:15 +08:00
Pierre Tachoire
54fa3bc054 remove children from previous parent 2026-01-06 11:52:47 +01:00
Pierre Tachoire
68f5fa738c remove dead code Page._appendNode 2026-01-06 11:49:13 +01:00
Karl Seguin
2ea57ba979 update v8 dep 2026-01-06 18:30:25 +08:00
Karl Seguin
1acc0b0dc8 Merge pull request #1310 from lightpanda-io/zigdom-named-access
Named Access on the Window Object
2026-01-06 18:07:43 +08:00
Karl Seguin
645ec79fce access page from context, document call_depth usage 2026-01-06 18:04:17 +08:00
Karl Seguin
97e897e80e Merge pull request #1318 from lightpanda-io/node-self-replace
handle Node self replacement in insertBefore
2026-01-06 17:23:12 +08:00
Karl Seguin
6f72eeae65 Merge pull request #1319 from lightpanda-io/script_list_cleanup
Handle immediate call to Script.errorCallback
2026-01-06 17:22:14 +08:00
Karl Seguin
a845b2e35e Handle immediate call to Script.errorCallback
It's possible for Script.errorCallback to be called as part of the call to
`client.request`. This happens because we eagerly pump the libcurl message loop
to get the request going ASAP. For very obvious failures (e.g. an invalid URL)
this means that the error callback can be called from `client.request`.

Previously, we were only adding the script to its list _after_ the call to
`client.request`, but the error handler tries to remove the script from the list
.

This commit changes the order so that the script is first added to the list
and then the request is made.
2026-01-06 17:03:27 +08:00
Pierre Tachoire
b164ffeb95 handle Node self replacement in insertBefore 2026-01-06 09:49:08 +01:00
Karl Seguin
7ba34af884 Merge pull request #1317 from lightpanda-io/zigValueToJs-opts-pass-down
pass down opts to zigValueToJs
2026-01-06 16:36:44 +08:00
Pierre Tachoire
7f543ac7c8 pass down opts to zigValueToJs 2026-01-06 09:35:38 +01:00
Karl Seguin
a1bf92c07f Reject constructors called as function (i.e. without 'new')
Previously, `MessageEvent('')` would have been allowed, but invalid. This caused
problems as the receiver was the window. All such calls are now rejected.

Depends on https://github.com/lightpanda-io/zig-v8-fork/pull/131
2026-01-06 16:03:37 +08:00
Karl Seguin
0b221615b7 Merge pull request #1315 from lightpanda-io/replaceChild-itself
fix Node.replaceChild when of new child equals old
2026-01-06 08:00:03 +08:00
Pierre Tachoire
f81a9b54a7 fix Node.replaceChild when of new child equals old 2026-01-05 21:48:59 +01:00
Muki Kiboigo
05da040ce1 increment call_depth on callWithThis 2026-01-05 09:27:17 -08:00
Muki Kiboigo
b911051842 add named access shadowing test 2026-01-05 09:08:57 -08:00
Muki Kiboigo
a67f46b550 add named access on the Window object 2026-01-05 08:41:42 -08:00
58 changed files with 2287 additions and 350 deletions

View File

@@ -13,7 +13,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
default: 'v0.1.37'
default: 'v0.2.2'
v8:
description: 'v8 version to install'
required: false

View File

@@ -5,8 +5,12 @@ env:
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
on:
push:
tags:
- '*'
schedule:
- cron: "2 2 * * *"
@@ -38,8 +42,11 @@ jobs:
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -54,7 +61,7 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
build-linux-aarch64:
env:
@@ -77,8 +84,11 @@ jobs:
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -93,7 +103,7 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
build-macos-aarch64:
env:
@@ -118,8 +128,11 @@ jobs:
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -134,7 +147,7 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}
build-macos-x86_64:
env:
@@ -157,8 +170,11 @@ jobs:
arch: ${{env.ARCH}}
mode: 'release'
- name: v8 snapshot
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
- name: zig build
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
- name: Rename binary
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
@@ -173,4 +189,4 @@ jobs:
with:
allowUpdates: true
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
tag: nightly
tag: ${{ env.RELEASE }}

View File

@@ -3,7 +3,7 @@ FROM debian:stable-slim
ARG MINISIG=0.12
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
ARG ZIG_V8=v0.1.37
ARG ZIG_V8=v0.2.2
ARG TARGETPLATFORM
RUN apt-get update -yq && \
@@ -48,8 +48,16 @@ RUN case $TARGETPLATFORM in \
mkdir -p v8/ && \
mv libc_v8.a v8/libc_v8.a
# build v8 snapshot
RUN zig build -Doptimize=ReleaseFast \
-Dprebuilt_v8_path=v8/libc_v8.a \
snapshot_creator -- src/snapshot.bin
# build release
RUN zig build -Doptimize=ReleaseFast -Dprebuilt_v8_path=v8/libc_v8.a -Dgit_commit=$(git rev-parse --short HEAD)
RUN zig build -Doptimize=ReleaseFast \
-Dsnapshot_path=../../snapshot.bin \
-Dprebuilt_v8_path=v8/libc_v8.a \
-Dgit_commit=$(git rev-parse --short HEAD)
FROM debian:stable-slim

View File

@@ -47,12 +47,18 @@ help:
# $(ZIG) commands
# ------------
.PHONY: build build-dev run run-release shell test bench wpt data end2end
.PHONY: build build-v8-snapshot build-dev run run-release shell test bench wpt data end2end
## Build in release-safe mode
build:
## Build v8 snapshot
build-v8-snapshot:
@printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in release-fast mode
build: build-v8-snapshot
@printf "\033[36mBuilding (release safe)...\033[0m\n"
@$(ZIG) build -Doptimize=ReleaseSafe -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
@printf "\033[33mBuild OK\033[0m\n"
## Build in debug mode

View File

@@ -211,6 +211,23 @@ env.
But you can directly use the zig command: `zig build run`.
#### Embed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
```
zig build snapshot_creator -- src/snapshot.bin
```
Build using the snapshot binary.
```
zig build -Dsnapshot_path=../../snapshot.bin
```
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for more details.
## Test
### Unit Tests

View File

@@ -6,8 +6,8 @@
.minimum_zig_version = "0.15.2",
.dependencies = .{
.v8 = .{
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0d64a3d5b36ac94067df3e13fddbf715caa6f391.tar.gz",
.hash = "v8-0.0.0-xddH65sfBAC8o3q41YxhOms5uY2fvMzBrsgN8IeCXZgE",
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/d6b5f89cfc7feece29359e8c848bb916e8ecfab6.tar.gz",
.hash = "v8-0.0.0-xddH6_0gBABrJc5cL6-P2wGvvweTTCgWdpmClr9r-C-s",
},
//.v8 = .{ .path = "../zig-v8-fork" },
.@"boringssl-zig" = .{

View File

@@ -40,7 +40,8 @@ arena: Allocator,
listener_pool: std.heap.MemoryPool(Listener),
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
lookup: std.AutoHashMapUnmanaged(usize, *std.DoublyLinkedList),
dispatch_depth: u32 = 0,
dispatch_depth: usize,
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
pub fn init(page: *Page) EventManager {
return .{
@@ -50,6 +51,7 @@ pub fn init(page: *Page) EventManager {
.list_pool = std.heap.MemoryPool(std.DoublyLinkedList).init(page.arena),
.listener_pool = std.heap.MemoryPool(Listener).init(page.arena),
.dispatch_depth = 0,
.deferred_removals = .{},
};
}
@@ -119,9 +121,6 @@ pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, call
}
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.remove", .{ .type = typ, .capture = use_capture, .target = target });
}
const list = self.lookup.get(@intFromPtr(target)) orelse return;
if (findListener(list, typ, callback, use_capture)) |listener| {
self.removeListener(list, listener);
@@ -186,7 +185,7 @@ pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *E
if (function_) |func| {
event._current_target = target;
if (func.call(void, .{event})) {
if (func.callWithThis(void, target, .{event})) {
was_dispatched = true;
} else |err| {
// a non-JS error
@@ -299,38 +298,53 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
const page = self.page;
const typ = event._type_string;
// Track that we're dispatching to prevent immediate removal
// Track dispatch depth for deferred removal
self.dispatch_depth += 1;
defer {
self.dispatch_depth -= 1;
// Clean up any marked listeners in this target's list after this phase
// We do this regardless of depth to handle cross-target removals correctly
self.cleanupMarkedListeners(list);
const dispatch_depth = self.dispatch_depth;
// Only destroy deferred listeners when we exit the outermost dispatch
if (dispatch_depth == 1) {
for (self.deferred_removals.items) |removal| {
removal.list.remove(&removal.listener.node);
self.listener_pool.destroy(removal.listener);
}
self.deferred_removals.clearRetainingCapacity();
} else {
self.dispatch_depth = dispatch_depth - 1;
}
}
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
const last_node = list.last orelse return;
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
// Iterate through the list, stopping after we've encountered the last_listener
var node = list.first;
var is_done = false;
while (node) |n| {
// do this now, in case we need to remove n (once: true or aborted signal)
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
// Skip listeners that were marked for removal
if (listener.marked_for_removal) {
continue;
if (is_done) {
break;
}
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
is_done = (listener == last_listener);
node = n.next;
// Skip non-matching listeners
if (!listener.typ.eql(typ)) {
continue;
}
// Can be null when dispatching to the target itself
if (comptime capture_only) |capture| {
if (listener.capture != capture) {
continue;
}
}
// Skip removed listeners
if (listener.removed) {
continue;
}
// If the listener has an aborted signal, remove it and skip
if (listener.signal) |signal| {
if (signal.getAborted()) {
@@ -339,6 +353,11 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
}
}
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
if (listener.once) {
self.removeListener(list, listener);
}
was_handled.* = true;
event._current_target = current_target;
@@ -349,7 +368,7 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
}
switch (listener.function) {
.value => |value| try value.call(void, .{event}),
.value => |value| try value.callWithThis(void, current_target, .{event}),
.string => |string| {
const str = try page.call_arena.dupeZ(u8, string.str());
try self.page.js.eval(str, null);
@@ -366,10 +385,6 @@ fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_targe
event._target = original_target;
}
if (listener.once) {
self.removeListener(list, listener);
}
if (event._stop_immediate_propagation) {
return;
}
@@ -382,29 +397,17 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target:
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
// If we're in a dispatch, defer removal to avoid invalidating iteration
if (self.dispatch_depth > 0) {
// We're in the middle of dispatching, just mark for removal
// This prevents invalidating the linked list during iteration
listener.marked_for_removal = true;
listener.removed = true;
self.deferred_removals.append(self.arena, .{ .list = list, .listener = listener }) catch unreachable;
} else {
// Safe to remove immediately
// Outside dispatch, remove immediately
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
fn cleanupMarkedListeners(self: *EventManager, list: *std.DoublyLinkedList) void {
var node = list.first;
while (node) |n| {
node = n.next;
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
if (listener.marked_for_removal) {
list.remove(&listener.node);
self.listener_pool.destroy(listener);
}
}
}
fn findListener(list: *const std.DoublyLinkedList, typ: []const u8, callback: Callback, capture: bool) ?*Listener {
var node = list.first;
while (node) |n| {
@@ -436,7 +439,7 @@ const Listener = struct {
function: Function,
signal: ?*@import("webapi/AbortSignal.zig") = null,
node: std.DoublyLinkedList.Node,
marked_for_removal: bool = false,
removed: bool = false,
};
const Function = union(enum) {

View File

@@ -168,6 +168,18 @@ pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
return chain.get(1);
}
fn eventInit(typ: []const u8, value: anytype, page: *Page) !Event {
// Round to 2ms for privacy (browsers do this)
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
const time_stamp = (raw_timestamp / 2) * 2;
return .{
._type = unionInit(Event.Type, value),
._type_string = try String.init(page.arena, typ, .{}),
._time_stamp = time_stamp,
};
}
// this is a root object
pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
const allocator = self._slab.allocator();
@@ -178,10 +190,7 @@ pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(Event.Type, chain.get(1)),
._type_string = try String.init(self._page.arena, typ, .{}),
};
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
chain.setLeaf(1, child);
return chain.get(1);
@@ -196,10 +205,7 @@ pub fn uiEvent(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child)
// Special case: Event has a _type_string field, so we need manual setup
const event_ptr = chain.get(0);
event_ptr.* = .{
._type = unionInit(Event.Type, chain.get(1)),
._type_string = try String.init(self._page.arena, typ, .{}),
};
event_ptr.* = try eventInit(typ, chain.get(1), self._page);
chain.setMiddle(1, UIEvent.Type);
chain.setLeaf(2, child);

View File

@@ -93,7 +93,9 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri
_element_styles: Element.StyleLookup = .{},
_element_datasets: Element.DatasetLookup = .{},
_element_class_lists: Element.ClassListLookup = .{},
_element_rel_lists: Element.RelListLookup = .{},
_element_shadow_roots: Element.ShadowRootLookup = .{},
_node_owner_documents: Node.OwnerDocumentLookup = .{},
_element_assigned_slots: Element.AssignedSlotLookup = .{},
_script_manager: ScriptManager,
@@ -263,7 +265,9 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._element_styles = .{};
self._element_datasets = .{};
self._element_class_lists = .{};
self._element_rel_lists = .{};
self._element_shadow_roots = .{};
self._node_owner_documents = .{};
self._element_assigned_slots = .{};
self._notified_network_idle = .init;
self._notified_network_almost_idle = .init;
@@ -993,21 +997,32 @@ pub fn domChanged(self: *Page) void {
};
}
fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) {
const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) };
fn getElementIdMap(page: *Page, node: *Node) ElementIdMaps {
// Walk up the tree checking for ShadowRoot and tracking the root
var current = node;
while (true) {
if (current.is(ShadowRoot)) |shadow_root| {
return &shadow_root._elements_by_id;
return .{
.lookup = &shadow_root._elements_by_id,
.removed_ids = &shadow_root._removed_ids,
};
}
const parent = current._parent orelse {
if (current._type == .document) {
return &current._type.document._elements_by_id;
return .{
.lookup = &current._type.document._elements_by_id,
.removed_ids = &current._type.document._removed_ids,
};
}
// Detached nodes should not have IDs registered
std.debug.assert(false);
return &page.document._elements_by_id;
return .{
.lookup = &page.document._elements_by_id,
.removed_ids = &page.document._removed_ids,
};
};
current = parent;
@@ -1015,22 +1030,35 @@ fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Elemen
}
pub fn addElementId(self: *Page, parent: *Node, element: *Element, id: []const u8) !void {
var id_map = self.getElementIdMap(parent);
const gop = try id_map.getOrPut(self.arena, id);
var id_maps = self.getElementIdMap(parent);
const gop = try id_maps.lookup.getOrPut(self.arena, id);
if (!gop.found_existing) {
gop.value_ptr.* = element;
return;
}
const existing = gop.value_ptr.*.asNode();
switch (element.asNode().compareDocumentPosition(existing)) {
0x04 => gop.value_ptr.* = element,
else => {},
}
}
pub fn removeElementId(self: *Page, element: *Element, id: []const u8) void {
var id_map = self.getElementIdMap(element.asNode());
_ = id_map.remove(id);
const node = element.asNode();
self.removeElementIdWithMaps(self.getElementIdMap(node), id);
}
pub fn removeElementIdWithMaps(self: *Page, id_maps: ElementIdMaps, id: []const u8) void {
if (id_maps.lookup.remove(id)) {
id_maps.removed_ids.put(self.arena, id, {}) catch {};
}
}
pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Element {
if (node.isConnected() or node.isInShadowTree()) {
const id_map = self.getElementIdMap(node);
return id_map.get(id);
const lookup = self.getElementIdMap(node).lookup;
return lookup.get(id);
}
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(node, .{});
while (tw.next()) |el| {
@@ -1287,6 +1315,26 @@ pub fn nodeComplete(self: *Page, node: *Node) !void {
return self.nodeIsReady(true, node);
}
// Sets the owner document for a node. Only stores entries for nodes whose owner
// is NOT page.document to minimize memory overhead.
pub fn setNodeOwnerDocument(self: *Page, node: *Node, owner: *Document) !void {
if (owner == self.document) {
// No need to store if it's the main document - remove if present
_ = self._node_owner_documents.remove(node);
} else {
try self._node_owner_documents.put(self.arena, node, owner);
}
}
// Recursively sets the owner document for a node and all its descendants
pub fn adoptNodeTree(self: *Page, node: *Node, new_owner: *Document) !void {
try self.setNodeOwnerDocument(node, new_owner);
var it = node.childrenIterator();
while (it.next()) |child| {
try self.adoptNodeTree(child, new_owner);
}
}
pub fn createElement(self: *Page, ns_: ?[]const u8, name: []const u8, attribute_iterator: anytype) !*Node {
const namespace: Element.Namespace = blk: {
const ns = ns_ orelse break :blk .html;
@@ -2095,7 +2143,7 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
// grab this before we null the parent
const was_connected = child.isConnected();
// Capture the ID map before disconnecting, so we can remove IDs from the correct document
const id_map = if (was_connected) self.getElementIdMap(child) else null;
const id_maps = if (was_connected) self.getElementIdMap(child) else null;
child._parent = null;
child._child_link = .{};
@@ -2146,7 +2194,7 @@ pub fn removeNode(self: *Page, parent: *Node, child: *Node, opts: RemoveNodeOpts
var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{});
while (tw.next()) |el| {
if (el.getAttributeSafe("id")) |id| {
_ = id_map.?.remove(id);
self.removeElementIdWithMaps(id_maps.?, id);
}
Element.Html.Custom.invokeDisconnectedCallbackOnElement(el, self);
@@ -2170,9 +2218,9 @@ pub fn appendAllChildren(self: *Page, parent: *Node, target: *Node) !void {
}
}
pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, target: *Node, ref_node: *Node) !void {
pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, parent: *Node, ref_node: *Node) !void {
self.domChanged();
const dest_connected = target.isConnected();
const dest_connected = parent.isConnected();
var it = fragment.childrenIterator();
while (it.next()) |child| {
@@ -2180,7 +2228,7 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, target: *Node, ref_
const child_was_connected = child.isConnected();
self.removeNode(fragment, child, .{ .will_be_reconnected = dest_connected });
try self.insertNodeRelative(
target,
parent,
child,
.{ .before = ref_node },
.{ .child_already_connected = child_was_connected },
@@ -2188,10 +2236,6 @@ pub fn insertAllChildrenBefore(self: *Page, fragment: *Node, target: *Node, ref_
}
}
fn _appendNode(self: *Page, comptime from_parser: bool, parent: *Node, child: *Node, opts: InsertNodeOpts) !void {
self._insertNodeRelative(from_parser, parent, child, .append, opts);
}
const InsertNodeRelative = union(enum) {
append,
after: *Node,

View File

@@ -239,8 +239,17 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
};
const is_blocking = script.mode == .normal;
if (is_blocking == false) {
self.scriptList(script).append(&script.node);
}
if (remote_url) |url| {
errdefer script.deinit(true);
errdefer {
if (is_blocking == false) {
self.scriptList(script).remove(&script.node);
}
script.deinit(true);
}
var headers = try self.client.newHeaders();
try page.requestCookie(.{}).headersForRequest(page.arena, url, &headers);
@@ -271,8 +280,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
}
if (is_blocking == false) {
const list = self.scriptList(script);
list.append(&script.node);
return;
}

295
src/browser/css/Parser.zig Normal file
View File

@@ -0,0 +1,295 @@
// Copyright (C) 2023-2025 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 Tokenizer = @import("Tokenizer.zig");
pub const Declaration = struct {
name: []const u8,
value: []const u8,
important: bool,
};
const TokenSpan = struct {
token: Tokenizer.Token,
start: usize,
end: usize,
};
const TokenStream = struct {
tokenizer: Tokenizer,
peeked: ?TokenSpan = null,
fn init(input: []const u8) TokenStream {
return .{ .tokenizer = .{ .input = input } };
}
fn nextRaw(self: *TokenStream) ?TokenSpan {
const start = self.tokenizer.position;
const token = self.tokenizer.next() orelse return null;
const end = self.tokenizer.position;
return .{ .token = token, .start = start, .end = end };
}
fn next(self: *TokenStream) ?TokenSpan {
if (self.peeked) |token| {
self.peeked = null;
return token;
}
return self.nextRaw();
}
fn peek(self: *TokenStream) ?TokenSpan {
if (self.peeked == null) {
self.peeked = self.nextRaw();
}
return self.peeked;
}
};
pub fn parseDeclarationsList(input: []const u8) DeclarationsIterator {
return DeclarationsIterator.init(input);
}
pub const DeclarationsIterator = struct {
input: []const u8,
stream: TokenStream,
pub fn init(input: []const u8) DeclarationsIterator {
return .{
.input = input,
.stream = TokenStream.init(input),
};
}
pub fn next(self: *DeclarationsIterator) ?Declaration {
while (true) {
self.skipTriviaAndSemicolons();
const peeked = self.stream.peek() orelse return null;
switch (peeked.token) {
.at_keyword => {
_ = self.stream.next();
self.skipAtRule();
},
.ident => |name| {
_ = self.stream.next();
if (self.consumeDeclaration(name)) |declaration| {
return declaration;
}
},
else => {
_ = self.stream.next();
self.skipInvalidDeclaration();
},
}
}
return null;
}
fn consumeDeclaration(self: *DeclarationsIterator, name: []const u8) ?Declaration {
self.skipTrivia();
const colon = self.stream.next() orelse return null;
if (!isColon(colon.token)) {
self.skipInvalidDeclaration();
return null;
}
const value = self.consumeValue() orelse return null;
return .{
.name = name,
.value = value.value,
.important = value.important,
};
}
const ValueResult = struct {
value: []const u8,
important: bool,
};
fn consumeValue(self: *DeclarationsIterator) ?ValueResult {
self.skipTrivia();
var depth: usize = 0;
var start: ?usize = null;
var last_sig: ?TokenSpan = null;
var prev_sig: ?TokenSpan = null;
while (true) {
const peeked = self.stream.peek() orelse break;
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
break;
}
const span = self.stream.next() orelse break;
if (isWhitespaceOrComment(span.token)) {
continue;
}
if (start == null) start = span.start;
prev_sig = last_sig;
last_sig = span;
updateDepth(span.token, &depth);
}
const value_start = start orelse return null;
const last = last_sig orelse return null;
var important = false;
var end_pos = last.end;
if (isImportantPair(prev_sig, last)) {
important = true;
const bang = prev_sig orelse return null;
if (value_start >= bang.start) return null;
end_pos = bang.start;
}
var value_slice = self.input[value_start..end_pos];
value_slice = std.mem.trim(u8, value_slice, &std.ascii.whitespace);
if (value_slice.len == 0) return null;
return .{ .value = value_slice, .important = important };
}
fn skipTrivia(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (!isWhitespaceOrComment(peeked.token)) break;
_ = self.stream.next();
}
}
fn skipTriviaAndSemicolons(self: *DeclarationsIterator) void {
while (self.stream.peek()) |peeked| {
if (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token)) {
_ = self.stream.next();
} else {
break;
}
}
}
fn skipAtRule(self: *DeclarationsIterator) void {
var depth: usize = 0;
var saw_block = false;
while (true) {
const peeked = self.stream.peek() orelse return;
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
if (isBlockStart(span.token)) {
depth += 1;
saw_block = true;
} else if (isBlockEnd(span.token)) {
if (depth > 0) depth -= 1;
if (saw_block and depth == 0) return;
}
}
}
fn skipInvalidDeclaration(self: *DeclarationsIterator) void {
var depth: usize = 0;
while (self.stream.peek()) |peeked| {
if (isSemicolon(peeked.token) and depth == 0) {
_ = self.stream.next();
return;
}
const span = self.stream.next() orelse return;
if (isWhitespaceOrComment(span.token)) continue;
updateDepth(span.token, &depth);
}
}
};
fn isWhitespaceOrComment(token: Tokenizer.Token) bool {
return switch (token) {
.white_space, .comment => true,
else => false,
};
}
fn isSemicolon(token: Tokenizer.Token) bool {
return switch (token) {
.semicolon => true,
else => false,
};
}
fn isColon(token: Tokenizer.Token) bool {
return switch (token) {
.colon => true,
else => false,
};
}
fn isBlockStart(token: Tokenizer.Token) bool {
return switch (token) {
.curly_bracket_block, .square_bracket_block, .parenthesis_block, .function => true,
else => false,
};
}
fn isBlockEnd(token: Tokenizer.Token) bool {
return switch (token) {
.close_curly_bracket, .close_parenthesis, .close_square_bracket => true,
else => false,
};
}
fn updateDepth(token: Tokenizer.Token, depth: *usize) void {
if (isBlockStart(token)) {
depth.* += 1;
return;
}
if (isBlockEnd(token)) {
if (depth.* > 0) depth.* -= 1;
}
}
fn isImportantPair(prev_sig: ?TokenSpan, last_sig: TokenSpan) bool {
if (!isIdentImportant(last_sig.token)) return false;
const prev = prev_sig orelse return false;
return isBang(prev.token);
}
fn isIdentImportant(token: Tokenizer.Token) bool {
return switch (token) {
.ident => |name| std.ascii.eqlIgnoreCase(name, "important"),
else => false,
};
}
fn isBang(token: Tokenizer.Token) bool {
return switch (token) {
.delim => |c| c == '!',
else => false,
};
}

View File

@@ -644,8 +644,10 @@ fn consumeNumeric(self: *Tokenizer) Token {
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
// TODO: true url parser
if (self.nextByte()) |it| {
self.consumeString(it == '\'');
return self.consumeString(it == '\'');
}
return null;
}
fn consumeIdentLike(self: *Tokenizer) Token {

View File

@@ -89,6 +89,10 @@ pub const CallOpts = struct {
};
pub fn constructor(self: *Caller, comptime T: type, func: anytype, info: v8.FunctionCallbackInfo, comptime opts: CallOpts) void {
if (!info.isConstructCall()) {
self.handleError(T, @TypeOf(func), error.InvalidArgument, info, opts);
return;
}
self._constructor(func, info) catch |err| {
self.handleError(T, @TypeOf(func), err, info, opts);
};

View File

@@ -203,27 +203,6 @@ fn trackCallback(self: *Context, pf: PersistentFunction) !void {
return self.callbacks.append(self.arena, pf);
}
// Given an anytype, turns it into a v8.Object. The anytype could be:
// 1 - A V8.object already
// 2 - Our js.Object wrapper around a V8.Object
// 3 - A zig instance that has previously been given to V8
// (i.e., the value has to be known to the executor)
pub fn valueToExistingObject(self: *const Context, value: anytype) !v8.Object {
if (@TypeOf(value) == v8.Object) {
return value;
}
if (@TypeOf(value) == js.Object) {
return value.js_obj;
}
const persistent_object = self.identity_map.get(@intFromPtr(value)) orelse {
return error.InvalidThisForCallback;
};
return persistent_object.castToObject();
}
// == Executors ==
pub fn eval(self: *Context, src: []const u8, name: ?[]const u8) !void {
_ = try self.exec(src, name);
@@ -452,7 +431,7 @@ pub fn zigValueToJs(self: *Context, value: anytype, comptime opts: Caller.CallOp
var js_arr = v8.Array.init(isolate, value.len);
var js_obj = js_arr.castTo(v8.Object);
for (value, 0..) |v, i| {
const js_val = try self.zigValueToJs(v, .{});
const js_val = try self.zigValueToJs(v, opts);
if (js_obj.setValueAtIndex(v8_context, @intCast(i), js_val) == false) {
return error.FailedToCreateArray;
}
@@ -577,7 +556,7 @@ pub fn zigValueToJs(self: *Context, value: anytype, comptime opts: Caller.CallOp
},
.optional => {
if (value) |v| {
return self.zigValueToJs(v, .{});
return self.zigValueToJs(v, opts);
}
// would be handled by simpleZigValueToJs
unreachable;

View File

@@ -74,26 +74,23 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool) !*Context
const env = self.env;
const isolate = env.isolate;
const arena = self.context_arena.allocator();
var v8_context: v8.Context = blk: {
var temp_scope: v8.HandleScope = undefined;
v8.HandleScope.init(&temp_scope, isolate);
defer temp_scope.deinit();
if (comptime IS_DEBUG) {
// Getting this into the snapshot is tricky (anything involving the
// global is tricky). Easier to do here, and in debug mode, we're
// fine with paying the small perf hit.
const js_global = v8.FunctionTemplate.initDefault(isolate);
const global_template = js_global.getInstanceTemplate();
// Creates a global template that inherits from Window.
const global_template = @import("Snapshot.zig").createGlobalTemplate(isolate, env.templates);
global_template.setNamedProperty(v8.NamedPropertyHandlerConfiguration{
.getter = unknownPropertyCallback,
.flags = v8.PropertyHandlerFlags.NonMasking | v8.PropertyHandlerFlags.OnlyInterceptStrings,
}, null);
}
// Add the named property handler
global_template.setNamedProperty(v8.NamedPropertyHandlerConfiguration{
.getter = unknownPropertyCallback,
.flags = v8.PropertyHandlerFlags.NonMasking | v8.PropertyHandlerFlags.OnlyInterceptStrings,
}, null);
const context_local = v8.Context.init(isolate, null, null);
const context_local = v8.Context.init(isolate, global_template, null);
const v8_context = v8.Persistent(v8.Context).init(isolate, context_local).castToContext();
break :blk v8_context;
};
@@ -124,7 +121,7 @@ pub fn createContext(self: *ExecutionWorld, page: *Page, enter: bool) !*Context
.handle_scope = handle_scope,
.script_manager = &page._script_manager,
.call_arena = page.call_arena,
.arena = self.context_arena.allocator(),
.arena = arena,
};
var context = &self.context.?;
@@ -159,9 +156,9 @@ pub fn resumeExecution(self: *const ExecutionWorld) void {
pub fn unknownPropertyCallback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C_PropertyCallbackInfo) callconv(.c) u8 {
const info = v8.PropertyCallbackInfo.initFromV8(raw_info);
const context = Context.fromIsolate(info.getIsolate());
const property = context.valueToString(.{ .handle = c_name.? }, .{}) catch "???";
const context = Context.fromIsolate(info.getIsolate());
const maybe_property: ?[]u8 = context.valueToString(.{ .handle = c_name.? }, .{}) catch null;
const ignored = std.StaticStringMap(void).initComptime(.{
.{ "process", {} },
@@ -185,12 +182,26 @@ pub fn unknownPropertyCallback(c_name: ?*const v8.C_Name, raw_info: ?*const v8.C
.{ "CLOSURE_FLAGS", {} },
});
if (!ignored.has(property)) {
log.debug(.unknown_prop, "unkown global property", .{
.info = "but the property can exist in pure JS",
.stack = context.stackTrace() catch "???",
.property = property,
});
if (maybe_property) |prop| {
if (!ignored.has(prop)) {
const page = context.page;
const document = page.document;
if (document.getElementById(prop, page)) |el| {
const js_value = context.zigValueToJs(el, .{}) catch {
return v8.Intercepted.No;
};
info.getReturnValue().set(js_value);
return v8.Intercepted.Yes;
}
log.debug(.unknown_prop, "unknown global property", .{
.info = "but the property can exist in pure JS",
.stack = context.stackTrace() catch "???",
.property = prop,
});
}
}
return v8.Intercepted.No;

View File

@@ -116,7 +116,29 @@ pub fn tryCallWithThis(self: *const Function, comptime T: type, this: anytype, a
pub fn callWithThis(self: *const Function, comptime T: type, this: anytype, args: anytype) !T {
const context = self.context;
const js_this = try context.valueToExistingObject(this);
// When we're calling a function from within JavaScript itself, this isn't
// necessary. We're within a Caller instantiation, which will already have
// incremented the call_depth and it won't decrement it until the Caller is
// done.
// But some JS functions are initiated from Zig code, and not v8. For
// example, Observers, some event and window callbacks. In those cases, we
// need to increase the call_depth so that the call_arena remains valid for
// the duration of the function call. If we don't do this, the call_arena
// will be reset after each statement of the function which executes Zig code.
const call_depth = context.call_depth;
context.call_depth = call_depth + 1;
defer context.call_depth = call_depth;
const js_this = blk: {
if (@TypeOf(this) == v8.Object) {
break :blk this;
}
if (@TypeOf(this) == js.Object) {
break :blk this.js_obj;
}
break :blk try context.zigValueToJs(this, .{});
};
const aargs = if (comptime @typeInfo(@TypeOf(args)) == .null) struct {}{} else args;

View File

@@ -54,7 +54,7 @@ pub fn set(self: Object, key: []const u8, value: anytype, opts: SetOpts) error{
const context = self.context;
const js_key = v8.String.initUtf8(context.isolate, key);
const js_value = try context.zigValueToJs(value);
const js_value = try context.zigValueToJs(value, .{});
const res = self.js_obj.defineOwnProperty(context.v8_context, js_key.toName(), js_value, @bitCast(opts)) orelse false;
if (!res) {

View File

@@ -113,6 +113,17 @@ fn isValid(self: Snapshot) bool {
return v8.SnapshotCreator.startupDataIsValid(self.startup_data);
}
pub fn createGlobalTemplate(isolate: v8.Isolate, templates: []const v8.FunctionTemplate) v8.ObjectTemplate {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const js_global = v8.FunctionTemplate.initDefault(isolate);
js_global.setClassName(v8.String.initUtf8(isolate, "Window"));
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
js_global.inherit(templates[window_index]);
return js_global.getInstanceTemplate();
}
pub fn create(allocator: Allocator) !Snapshot {
var external_references = collectExternalReferences();
@@ -154,14 +165,7 @@ pub fn create(allocator: Allocator) !Snapshot {
// Set up the global template to inherit from Window's template
// This way the global object gets all Window properties through inheritance
const js_global = v8.FunctionTemplate.initDefault(isolate);
js_global.setClassName(v8.String.initUtf8(isolate, "Window"));
// Find Window in JsApis by name (avoids circular import)
const window_index = comptime bridge.JsApiLookup.getId(Window.JsApi);
js_global.inherit(templates[window_index]);
const global_template = js_global.getInstanceTemplate();
const global_template = createGlobalTemplate(isolate, templates[0..]);
const context = v8.Context.init(isolate, global_template, null);
context.enter();
@@ -407,7 +411,7 @@ fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionT
},
bridge.Function => {
const function_template = v8.FunctionTemplate.initCallback(isolate, value.func);
const js_name: v8.Name = v8.String.initUtf8(isolate, name).toName();
const js_name = v8.String.initUtf8(isolate, name).toName();
if (value.static) {
template.set(js_name, function_template, v8.PropertyAttribute.None);
} else {
@@ -456,6 +460,12 @@ fn attachClass(comptime JsApi: type, isolate: v8.Isolate, template: v8.FunctionT
instance_template.markAsUndetectable();
instance_template.setCallAsFunctionHandler(JsApi.Meta.callable.func);
}
if (@hasDecl(JsApi.Meta, "name")) {
const js_name = v8.Symbol.getToStringTag(isolate).toName();
const instance_template = template.getInstanceTemplate();
instance_template.set(js_name, v8.String.initUtf8(isolate, JsApi.Meta.name), v8.PropertyAttribute.ReadOnly + v8.PropertyAttribute.DontDelete);
}
}
fn protoIndexLookup(comptime JsApi: type) ?bridge.JsApiLookup.BackingInt {

View File

@@ -41,6 +41,14 @@ pub fn isArray(self: Value) bool {
return self.js_val.isArray();
}
pub fn isNull(self: Value) bool {
return self.js_val.isNull();
}
pub fn isUndefined(self: Value) bool {
return self.js_val.isUndefined();
}
pub fn toString(self: Value, allocator: Allocator) ![]const u8 {
return self.context.valueToString(self.js_val, .{ .allocator = allocator });
}
@@ -61,6 +69,10 @@ pub fn persist(self: Value) !Value {
return Value{ .context = context, .js_val = persisted.toValue() };
}
pub fn toZig(self: Value, comptime T: type) !T {
return self.context.jsValueToZig(T, self.js_val);
}
pub fn toObject(self: Value) js.Object {
return .{
.context = self.context,

View File

@@ -98,6 +98,29 @@ pub fn parse(self: *Parser, html: []const u8) void {
);
}
pub fn parseXML(self: *Parser, xml: []const u8) void {
h5e.xml5ever_parse_document(
xml.ptr,
xml.len,
&self.container,
self,
createElementCallback,
getDataCallback,
appendCallback,
parseErrorCallback,
popCallback,
createCommentCallback,
createProcessingInstruction,
appendDoctypeToDocument,
addAttrsIfMissingCallback,
getTemplateContentsCallback,
removeFromParentCallback,
reparentChildrenCallback,
appendBeforeSiblingCallback,
appendBasedOnParentNodeCallback,
);
}
pub fn parseFragment(self: *Parser, html: []const u8) void {
h5e.html5ever_parse_fragment(
html.ptr,

View File

@@ -171,3 +171,24 @@ pub const NodeOrText = extern struct {
text: []const u8,
};
};
pub extern "c" fn xml5ever_parse_document(
html: [*c]const u8,
len: usize,
doc: *anyopaque,
ctx: *anyopaque,
createElementCallback: *const fn (ctx: *anyopaque, data: *anyopaque, QualName, AttributeIterator) callconv(.c) ?*anyopaque,
elemNameCallback: *const fn (node_ref: *anyopaque) callconv(.c) *anyopaque,
appendCallback: *const fn (ctx: *anyopaque, parent_ref: *anyopaque, NodeOrText) callconv(.c) void,
parseErrorCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) void,
popCallback: *const fn (ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void,
createCommentCallback: *const fn (ctx: *anyopaque, StringSlice) callconv(.c) ?*anyopaque,
createProcessingInstruction: *const fn (ctx: *anyopaque, StringSlice, StringSlice) callconv(.c) ?*anyopaque,
appendDoctypeToDocument: *const fn (ctx: *anyopaque, StringSlice, StringSlice, StringSlice) callconv(.c) void,
addAttrsIfMissingCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque, AttributeIterator) callconv(.c) void,
getTemplateContentsCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) ?*anyopaque,
removeFromParentCallback: *const fn (ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void,
reparentChildrenCallback: *const fn (ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void,
appendBeforeSiblingCallback: *const fn (ctx: *anyopaque, sibling_ref: *anyopaque, NodeOrText) callconv(.c) void,
appendBasedOnParentNodeCallback: *const fn (ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, NodeOrText) callconv(.c) void,
) void;

View File

@@ -107,19 +107,6 @@
}
</script>
<script id=unsupportedMimeType>
{
const parser = new DOMParser();
// Should throw an error for unsupported MIME types
testing.withError((err) => {
testing.expectEqual('NotSupported', err.message);
}, () => {
parser.parseFromString('<div>test</div>', 'application/xml');
});
}
</script>
<script id=getElementById>
{
const doc = new DOMParser().parseFromString('<div id="new-node">new-node</div>', 'text/html');
@@ -244,3 +231,161 @@
testing.expectEqual('<html><head></head><body>spice</body></html>', new DOMParser().parseFromString('spice', "text/html").documentElement.outerHTML);
testing.expectEqual('<html><head></head><body></body></html>', new DOMParser().parseFromString('<html></html>', "text/html").documentElement.outerHTML);
</script>
<script id=parse-xml>
{
const sampleXML = `<?xml version="1.0"?>
<catalog>
<book id="bk101">
<author>Gambardella, Matthew</author>
<title>XML Developer's Guide</title>
<genre>Computer</genre>
<price>44.95</price>
<publish_date>2000-10-01</publish_date>
<description>An in-depth look at creating applications
with XML.</description>
</book>
<book id="bk102">
<author>Ralls, Kim</author>
<title>Midnight Rain</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2000-12-16</publish_date>
<description>A former architect battles corporate zombies,
an evil sorceress, and her own childhood to become queen
of the world.</description>
</book>
<book id="bk103">
<author>Corets, Eva</author>
<title>Maeve Ascendant</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2000-11-17</publish_date>
<description>After the collapse of a nanotechnology
society in England, the young survivors lay the
foundation for a new society.</description>
</book>
<book id="bk104">
<author>Corets, Eva</author>
<title>Oberon's Legacy</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2001-03-10</publish_date>
<description>In post-apocalypse England, the mysterious
agent known only as Oberon helps to create a new life
for the inhabitants of London. Sequel to Maeve
Ascendant.</description>
</book>
<book id="bk105">
<author>Corets, Eva</author>
<title>The Sundered Grail</title>
<genre>Fantasy</genre>
<price>5.95</price>
<publish_date>2001-09-10</publish_date>
<description>The two daughters of Maeve, half-sisters,
battle one another for control of England. Sequel to
Oberon's Legacy.</description>
</book>
<book id="bk106">
<author>Randall, Cynthia</author>
<title>Lover Birds</title>
<genre>Romance</genre>
<price>4.95</price>
<publish_date>2000-09-02</publish_date>
<description>When Carla meets Paul at an ornithology
conference, tempers fly as feathers get ruffled.</description>
</book>
<book id="bk107">
<author>Thurman, Paula</author>
<title>Splish Splash</title>
<genre>Romance</genre>
<price>4.95</price>
<publish_date>2000-11-02</publish_date>
<description>A deep sea diver finds true love twenty
thousand leagues beneath the sea.</description>
</book>
<book id="bk108">
<author>Knorr, Stefan</author>
<title>Creepy Crawlies</title>
<genre>Horror</genre>
<price>4.95</price>
<publish_date>2000-12-06</publish_date>
<description>An anthology of horror stories about roaches,
centipedes, scorpions and other insects.</description>
</book>
<book id="bk109">
<author>Kress, Peter</author>
<title>Paradox Lost</title>
<genre>Science Fiction</genre>
<price>6.95</price>
<publish_date>2000-11-02</publish_date>
<description>After an inadvertant trip through a Heisenberg
Uncertainty Device, James Salway discovers the problems
of being quantum.</description>
</book>
<book id="bk110">
<author>O'Brien, Tim</author>
<title>Microsoft .NET: The Programming Bible</title>
<genre>Computer</genre>
<price>36.95</price>
<publish_date>2000-12-09</publish_date>
<description>Microsoft's .NET initiative is explored in
detail in this deep programmer's reference.</description>
</book>
<book id="bk111">
<author>O'Brien, Tim</author>
<title>MSXML3: A Comprehensive Guide</title>
<genre>Computer</genre>
<price>36.95</price>
<publish_date>2000-12-01</publish_date>
<description>The Microsoft MSXML3 parser is covered in
detail, with attention to XML DOM interfaces, XSLT processing,
SAX and more.</description>
</book>
<book id="bk112">
<author>Galos, Mike</author>
<title>Visual Studio 7: A Comprehensive Guide</title>
<genre>Computer</genre>
<price>49.95</price>
<publish_date>2001-04-16</publish_date>
<description>Microsoft Visual Studio 7 is explored in depth,
looking at how Visual Basic, Visual C++, C#, and ASP+ are
integrated into a comprehensive development
environment.</description>
</book>
</catalog>`;
const parser = new DOMParser();
const mimes = [
"text/xml",
"application/xml",
"application/xhtml+xml",
"image/svg+xml",
];
for (const mime of mimes) {
const doc = parser.parseFromString(sampleXML, "text/xml");
const { firstChild: { childNodes, children: collection, tagName }, children } = doc;
// doc.
testing.expectEqual(true, doc instanceof XMLDocument);
testing.expectEqual(1, children.length);
// firstChild.
// TODO: Modern browsers expect this in lowercase.
testing.expectEqual("CATALOG", tagName);
testing.expectEqual(25, childNodes.length);
testing.expectEqual(12, collection.length);
// Check children of first child.
for (let i = 0; i < collection.length; i++) {
const {children: elements, id} = collection.item(i);
testing.expectEqual("bk" + (100 + i + 1), id);
// TODO: Modern browsers expect these in lowercase.
testing.expectEqual("AUTHOR", elements.item(0).tagName);
testing.expectEqual("TITLE", elements.item(1).tagName);
testing.expectEqual("GENRE", elements.item(2).tagName);
testing.expectEqual("PRICE", elements.item(3).tagName);
testing.expectEqual("PUBLISH_DATE", elements.item(4).tagName);
testing.expectEqual("DESCRIPTION", elements.item(5).tagName);
}
}
}
</script>

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="test">first</div>
<div id="test">second</div>
<script id=duplicateIds>
const first = document.getElementById('test');
testing.expectEqual('first', first.textContent);
first.remove();
const second = document.getElementById('test');
testing.expectEqual('second', second.textContent);
// second.remove();
// testing.expectEqual(null, document.getElementById('test'));
</script>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id=d1>hello <em>world</em></div>
<script id=outerHTML>
const d1 = $('#d1');
testing.expectEqual('<div id=\"d1\">hello <em>world</em></div>', d1.outerHTML);
d1.outerHTML = '<p id=p1>spice</p>';
// setting outerHTML doesn't update what d1 points to
testing.expectEqual('<div id="d1">hello <em>world</em></div>', d1.outerHTML);
// but it does update the document
testing.expectEqual(null, document.getElementById('d1'));
testing.expectEqual(true, document.getElementById('p1') != null);
testing.expectEqual('<p id="p1">spice</p>', document.getElementById('p1').outerHTML);
// testing.expectEqual(true, document.body.outerHTML.replaceAll(/\n/g, '').startsWith('<body><p id="p1">spice</p><script id="outerHTML">'));
// document.getElementById('p1').outerHTML = '';
// testing.expectEqual(null, document.getElementById('p1'));
// testing.expectEqual(true, document.body.outerHTML.replaceAll(/\n/g, '').startsWith('<body><script id="outerHTML">'));
</script>

View File

@@ -0,0 +1,53 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<script id="removeListenerDuringDispatch">
const target = document.createElement("div");
let listener1Called = 0;
let listener2Called = 0;
let listener3Called = 0;
function listener1() {
listener1Called++;
console.warn("listener1 called, removing listener2 and adding listener3");
target.removeEventListener("foo", listener2);
target.addEventListener("foo", listener3);
}
function listener2() {
listener2Called++;
console.warn("listener2 called (SHOULD NOT HAPPEN)");
}
function listener3() {
listener3Called++;
console.warn("listener3 called (SHOULD NOT HAPPEN IN FIRST DISPATCH)");
}
target.addEventListener("foo", listener1);
target.addEventListener("foo", listener2);
console.warn("Dispatching first event");
target.dispatchEvent(new Event("foo"));
console.warn("After first dispatch:");
console.warn(" listener1Called:", listener1Called);
console.warn(" listener2Called:", listener2Called);
console.warn(" listener3Called:", listener3Called);
testing.expectEqual(1, listener1Called);
testing.expectEqual(0, listener2Called);
testing.expectEqual(0, listener3Called);
console.warn("Dispatching second event");
target.dispatchEvent(new Event("foo"));
console.warn("After second dispatch:");
console.warn(" listener1Called:", listener1Called);
console.warn(" listener2Called:", listener2Called);
console.warn(" listener3Called:", listener3Called);
testing.expectEqual(2, listener1Called);
testing.expectEqual(0, listener2Called);
testing.expectEqual(1, listener3Called);
</script>

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<body></body>
<script id="adoptNode">
const old = document.implementation.createHTMLDocument("");
const div = old.createElement("div");
div.appendChild(old.createTextNode("text"));
testing.expectEqual(old, div.ownerDocument);
testing.expectEqual(old, div.firstChild.ownerDocument);
document.body.appendChild(div);
testing.expectEqual(document, div.ownerDocument);
testing.expectEqual(document, div.firstChild.ownerDocument);
</script>

View File

@@ -37,4 +37,7 @@
testing.expectEqual(null, c2.parentNode);
assertChildren([c3, c4], d1)
assertChildren([], d2)
testing.expectEqual(c3, d1.replaceChild(c3, c3));
assertChildren([c3, c4], d1)
</script>

View File

@@ -376,3 +376,447 @@
testing.expectEqual('Bold', fragment.childNodes[0].textContent);
}
</script>
<script id=offset_validation_setStart>
{
const range = document.createRange();
const p1 = $('#p1');
// Test setStart with offset beyond node length
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.setStart(p1, 999);
});
// Test with negative offset (wraps to large u32)
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.setStart(p1.firstChild, -1);
});
}
</script>
<script id=offset_validation_setEnd>
{
const range = document.createRange();
const p1 = $('#p1');
// Test setEnd with offset beyond node length
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.setEnd(p1, 999);
});
// Test with text node
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.setEnd(p1.firstChild, 9999);
});
}
</script>
<script id=comparePoint_basic>
{
// Create fresh elements to avoid DOM pollution from other tests
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
p1.textContent = 'First paragraph text';
p2.textContent = 'Second paragraph text';
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(p1.firstChild, 0);
range.setEnd(p2.firstChild, 5);
// Point before range
testing.expectEqual(-1, range.comparePoint(div, 0));
// Point at start boundary
testing.expectEqual(0, range.comparePoint(p1.firstChild, 0));
// Point inside range (in p1)
testing.expectEqual(0, range.comparePoint(p1.firstChild, 3));
// Point inside range (in p2)
testing.expectEqual(0, range.comparePoint(p2.firstChild, 2));
// Point at end boundary
testing.expectEqual(0, range.comparePoint(p2.firstChild, 5));
// Point after range
testing.expectEqual(1, range.comparePoint(p2.firstChild, 10));
}
</script>
<script id=comparePoint_validation>
{
// Create fresh element
const p1 = document.createElement('p');
p1.textContent = 'Test content';
const range = document.createRange();
range.setStart(p1, 0);
range.setEnd(p1, 1);
// Test comparePoint with invalid offset
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.comparePoint(p1, 20);
});
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.comparePoint(p1.firstChild, -1);
});
}
</script>
<script id=different_document_collapse>
{
// Create fresh element in current document
const p1 = document.createElement('p');
p1.textContent = 'Local content';
const range = document.createRange();
// Create a foreign document
const foreignDoc = document.implementation.createHTMLDocument('');
const foreignP = foreignDoc.createElement('p');
foreignP.textContent = 'Foreign';
foreignDoc.body.appendChild(foreignP);
// Set up range in current document
range.setStart(p1, 0);
range.setEnd(p1, 1);
testing.expectEqual(false, range.collapsed);
// Setting start to foreign document should collapse to that point
range.setStart(foreignP, 0);
testing.expectEqual(true, range.collapsed);
testing.expectEqual(foreignP, range.startContainer);
testing.expectEqual(foreignP, range.endContainer);
}
</script>
<script id=detached_node_collapse>
{
// Create fresh element
const p1 = document.createElement('p');
p1.textContent = 'Attached content';
const range = document.createRange();
// Create a detached element
const detached = document.createElement('div');
detached.textContent = 'Detached';
// Set up range in document
range.setStart(p1, 0);
range.setEnd(p1, 1);
testing.expectEqual(false, range.collapsed);
// Setting end to detached node should collapse
range.setEnd(detached.firstChild, 0);
testing.expectEqual(true, range.collapsed);
testing.expectEqual(detached.firstChild, range.startContainer);
testing.expectEqual(detached.firstChild, range.endContainer);
}
</script>
<script id=isPointInRange_basic>
{
// Create fresh elements
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
p1.textContent = 'First paragraph';
p2.textContent = 'Second paragraph';
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(p1.firstChild, 5);
range.setEnd(p2.firstChild, 6);
// Point before range
testing.expectEqual(false, range.isPointInRange(p1.firstChild, 0));
// Point at start boundary
testing.expectEqual(true, range.isPointInRange(p1.firstChild, 5));
// Point inside range
testing.expectEqual(true, range.isPointInRange(p1.firstChild, 7));
testing.expectEqual(true, range.isPointInRange(p2.firstChild, 3));
// Point at end boundary
testing.expectEqual(true, range.isPointInRange(p2.firstChild, 6));
// Point after range
testing.expectEqual(false, range.isPointInRange(p2.firstChild, 10));
}
</script>
<script id=isPointInRange_different_root>
{
// Create element in current document
const p1 = document.createElement('p');
p1.textContent = 'Local content';
const range = document.createRange();
range.setStart(p1, 0);
range.setEnd(p1, 1);
// Create element in different document
const foreignDoc = document.implementation.createHTMLDocument('');
const foreignP = foreignDoc.createElement('p');
foreignP.textContent = 'Foreign';
// Point in different root should return false (not throw)
testing.expectEqual(false, range.isPointInRange(foreignP, 0));
}
</script>
<script id=isPointInRange_validation>
{
const p1 = document.createElement('p');
p1.textContent = 'Test content';
const range = document.createRange();
range.setStart(p1, 0);
range.setEnd(p1, 1);
// Invalid offset should throw IndexSizeError
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.isPointInRange(p1, 999);
});
testing.expectError('IndexSizeError: Index or size is negative or greater than the allowed amount', () => {
range.isPointInRange(p1.firstChild, 9999);
});
}
</script>
<script id=intersectsNode_basic>
{
// Create fresh elements
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
const p3 = document.createElement('p');
p1.textContent = 'First';
p2.textContent = 'Second';
p3.textContent = 'Third';
div.appendChild(p1);
div.appendChild(p2);
div.appendChild(p3);
const range = document.createRange();
range.setStart(p1.firstChild, 2);
range.setEnd(p2.firstChild, 3);
// Node that intersects (p1 contains the start)
testing.expectEqual(true, range.intersectsNode(p1));
// Node that intersects (p2 contains the end)
testing.expectEqual(true, range.intersectsNode(p2));
// Node that doesn't intersect (p3 is after the range)
testing.expectEqual(false, range.intersectsNode(p3));
// Container intersects
testing.expectEqual(true, range.intersectsNode(div));
}
</script>
<script id=intersectsNode_detached>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
p1.textContent = 'Content';
div.appendChild(p1);
const range = document.createRange();
range.setStart(p1, 0);
range.setEnd(p1, 1);
// The root node (div) should return true when it has no parent
// (Note: div is detached, so it's in the same tree as the range)
testing.expectEqual(true, range.intersectsNode(div));
}
</script>
<script id=intersectsNode_different_root>
{
const p1 = document.createElement('p');
p1.textContent = 'Local';
const range = document.createRange();
range.setStart(p1, 0);
range.setEnd(p1, 1);
// Node in different document should return false
const foreignDoc = document.implementation.createHTMLDocument('');
const foreignP = foreignDoc.createElement('p');
testing.expectEqual(false, range.intersectsNode(foreignP));
}
</script>
<script id=commonAncestorContainer_same_node>
{
const p = document.createElement('p');
p.textContent = 'Content';
const range = document.createRange();
range.setStart(p.firstChild, 0);
range.setEnd(p.firstChild, 3);
// Both boundaries in same text node, so that's the common ancestor
testing.expectEqual(p.firstChild, range.commonAncestorContainer);
}
</script>
<script id=commonAncestorContainer_siblings>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
p1.textContent = 'First';
p2.textContent = 'Second';
div.appendChild(p1);
div.appendChild(p2);
const range = document.createRange();
range.setStart(p1.firstChild, 0);
range.setEnd(p2.firstChild, 3);
// Start and end in different siblings, common ancestor is the parent div
testing.expectEqual(div, range.commonAncestorContainer);
}
</script>
<script id=commonAncestorContainer_nested>
{
const div = document.createElement('div');
const section = document.createElement('section');
const p = document.createElement('p');
const span = document.createElement('span');
p.textContent = 'Text';
span.textContent = 'Span';
div.appendChild(section);
section.appendChild(p);
div.appendChild(span);
const range = document.createRange();
range.setStart(p.firstChild, 0);
range.setEnd(span.firstChild, 2);
// Common ancestor of deeply nested p and sibling span is div
testing.expectEqual(div, range.commonAncestorContainer);
}
</script>
<script id=compareBoundaryPoints_constants>
{
// Test that the constants are defined
testing.expectEqual(0, Range.START_TO_START);
testing.expectEqual(1, Range.START_TO_END);
testing.expectEqual(2, Range.END_TO_END);
testing.expectEqual(3, Range.END_TO_START);
}
</script>
<script id=compareBoundaryPoints_basic>
{
const div = document.createElement('div');
const p1 = document.createElement('p');
const p2 = document.createElement('p');
p1.textContent = 'First paragraph';
p2.textContent = 'Second paragraph';
div.appendChild(p1);
div.appendChild(p2);
const range1 = document.createRange();
range1.setStart(p1.firstChild, 0);
range1.setEnd(p1.firstChild, 5);
const range2 = document.createRange();
range2.setStart(p1.firstChild, 3);
range2.setEnd(p2.firstChild, 5);
// range1 start is before range2 start
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.START_TO_START, range2));
// range1 start is before range2 end
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.START_TO_END, range2));
// range1 end is after range2 start
testing.expectEqual(1, range1.compareBoundaryPoints(Range.END_TO_START, range2));
// range1 end is before range2 end
testing.expectEqual(-1, range1.compareBoundaryPoints(Range.END_TO_END, range2));
}
</script>
<script id=compareBoundaryPoints_same_range>
{
const p = document.createElement('p');
p.textContent = 'Content';
const range = document.createRange();
range.setStart(p.firstChild, 2);
range.setEnd(p.firstChild, 5);
// Comparing a range to itself should return 0
testing.expectEqual(0, range.compareBoundaryPoints(Range.START_TO_START, range));
testing.expectEqual(0, range.compareBoundaryPoints(Range.END_TO_END, range));
// Start is before end
testing.expectEqual(-1, range.compareBoundaryPoints(Range.START_TO_END, range));
// End is after start
testing.expectEqual(1, range.compareBoundaryPoints(Range.END_TO_START, range));
}
</script>
<script id=compareBoundaryPoints_invalid_how>
{
const p = document.createElement('p');
p.textContent = 'Test';
const range1 = document.createRange();
const range2 = document.createRange();
range1.setStart(p, 0);
range2.setStart(p, 0);
// Invalid how parameter should throw NotSupportedError
testing.expectError('NotSupportedError: Not Supported', () => {
range1.compareBoundaryPoints(4, range2);
});
testing.expectError('NotSupportedError: Not Supported', () => {
range1.compareBoundaryPoints(99, range2);
});
}
</script>
<script id=compareBoundaryPoints_different_root>
{
const p1 = document.createElement('p');
p1.textContent = 'Local';
const range1 = document.createRange();
range1.setStart(p1, 0);
range1.setEnd(p1, 1);
// Create range in different document
const foreignDoc = document.implementation.createHTMLDocument('');
const foreignP = foreignDoc.createElement('p');
foreignP.textContent = 'Foreign';
const range2 = foreignDoc.createRange();
range2.setStart(foreignP, 0);
range2.setEnd(foreignP, 1);
// Comparing ranges in different documents should throw WrongDocumentError
testing.expectError('WrongDocumentError: wrong_document_error', () => {
range1.compareBoundaryPoints(Range.START_TO_START, range2);
});
}
</script>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id=i></div>
<div id=testDiv></div>
<span id=mySpan></span>
<p id=paragraph></p>
<script id=named_access_global>
testing.expectEqual('i', i.id);
testing.expectEqual('testDiv',testDiv.id);
testing.expectEqual('mySpan', mySpan.id);
testing.expectEqual('paragraph', paragraph.id);
</script>
<script id=named_access_window>
testing.expectEqual('i', window.i.id);
testing.expectEqual('testDiv', window.testDiv.id);
testing.expectEqual('mySpan', window.mySpan.id);
testing.expectEqual('paragraph', window.paragraph.id);
</script>
<script id=named_access_shadowing>
const i = 100;
testing.expectEqual(100, i);
</script>

View File

@@ -69,6 +69,19 @@ pub fn getCollapsed(self: *const AbstractRange) bool {
self._start_offset == self._end_offset;
}
pub fn getCommonAncestorContainer(self: *const AbstractRange) *Node {
// Let container be start container
var container = self._start_container;
// While container is not an inclusive ancestor of end container
while (!isInclusiveAncestorOf(container, self._end_container)) {
// Let container be container's parent
container = container.parentNode() orelse break;
}
return container;
}
pub fn isStartAfterEnd(self: *const AbstractRange) bool {
return compareBoundaryPoints(
self._start_container,
@@ -84,7 +97,7 @@ const BoundaryComparison = enum {
after,
};
fn compareBoundaryPoints(
pub fn compareBoundaryPoints(
node_a: *Node,
offset_a: u32,
node_b: *Node,
@@ -195,6 +208,13 @@ fn isAncestorOf(potential_ancestor: *Node, node: *Node) bool {
return false;
}
fn isInclusiveAncestorOf(potential_ancestor: *Node, node: *Node) bool {
if (potential_ancestor == node) {
return true;
}
return isAncestorOf(potential_ancestor, node);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(AbstractRange);
@@ -209,4 +229,5 @@ pub const JsApi = struct {
pub const endContainer = bridge.accessor(AbstractRange.getEndContainer, null, .{});
pub const endOffset = bridge.accessor(AbstractRange.getEndOffset, null, .{});
pub const collapsed = bridge.accessor(AbstractRange.getCollapsed, null, .{});
pub const commonAncestorContainer = bridge.accessor(AbstractRange.getCommonAncestorContainer, null, .{});
};

View File

@@ -147,7 +147,7 @@ pub fn format(self: *const CData, writer: *std.io.Writer) !void {
}
pub fn getLength(self: *const CData) usize {
return self._data.len;
return std.unicode.utf8CountCodepoints(self._data) catch self._data.len;
}
pub fn isEqualNode(self: *const CData, other: *const CData) bool {

View File

@@ -120,11 +120,6 @@ pub const JsApi = struct {
pub const createDocument = bridge.function(DOMImplementation.createDocument, .{});
pub const createHTMLDocument = bridge.function(DOMImplementation.createHTMLDocument, .{});
pub const hasFeature = bridge.function(DOMImplementation.hasFeature, .{});
pub const toString = bridge.function(_toString, .{});
fn _toString(_: *const DOMImplementation) []const u8 {
return "[object DOMImplementation]";
}
};
const testing = @import("../../testing.zig");

View File

@@ -19,8 +19,13 @@
const std = @import("std");
const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Parser = @import("../parser/Parser.zig");
const HTMLDocument = @import("HTMLDocument.zig");
const XMLDocument = @import("XMLDocument.zig");
const ProcessingInstruction = @import("../webapi/cdata/ProcessingInstruction.zig");
const DOMParser = @This();
@@ -28,34 +33,78 @@ pub fn init() DOMParser {
return .{};
}
pub fn parseFromString(self: *const DOMParser, html: []const u8, mime_type: []const u8, page: *Page) !*HTMLDocument {
_ = self;
pub const HTMLDocumentOrXMLDocument = union(enum) {
html_document: *HTMLDocument,
xml_document: *XMLDocument,
};
// For now, only support text/html
if (!std.mem.eql(u8, mime_type, "text/html")) {
return error.NotSupported;
}
pub fn parseFromString(
_: *const DOMParser,
html: []const u8,
mime_type: []const u8,
page: *Page,
) !HTMLDocumentOrXMLDocument {
const maybe_target_mime = std.meta.stringToEnum(enum {
@"text/html",
@"text/xml",
@"application/xml",
@"application/xhtml+xml",
@"image/svg+xml",
}, mime_type);
// Create a new HTMLDocument
const doc = try page._factory.document(HTMLDocument{
._proto = undefined,
});
if (maybe_target_mime) |target_mime| switch (target_mime) {
.@"text/html" => {
// Create a new HTMLDocument
const doc = try page._factory.document(HTMLDocument{
._proto = undefined,
});
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
if (normalized.len == 0) {
normalized = "<html></html>";
}
var normalized = std.mem.trim(u8, html, &std.ascii.whitespace);
if (normalized.len == 0) {
normalized = "<html></html>";
}
// Parse HTML into the document
const Parser = @import("../parser/Parser.zig");
var parser = Parser.init(page.arena, doc.asNode(), page);
parser.parse(normalized);
// Parse HTML into the document
var parser = Parser.init(page.arena, doc.asNode(), page);
parser.parse(normalized);
if (parser.err) |pe| {
return pe.err;
}
if (parser.err) |pe| {
return pe.err;
}
return doc;
return .{ .html_document = doc };
},
else => {
// Create a new XMLDocument.
const doc = try page._factory.document(XMLDocument{
._proto = undefined,
});
// Parse XML into XMLDocument.
const doc_node = doc.asNode();
var parser = Parser.init(page.arena, doc_node, page);
parser.parseXML(html);
if (parser.err) |pe| {
return pe.err;
}
// If first node is a `ProcessingInstruction`, skip it.
const first_child = doc_node.firstChild() orelse {
// Parsing should fail if there aren't any nodes.
unreachable;
};
if (first_child.getNodeType() == 7) {
// We're sure that firstChild exist, this cannot fail.
_ = doc_node.removeChild(first_child, page) catch unreachable;
}
return .{ .xml_document = doc };
},
};
return error.NotSupported;
}
pub const JsApi = struct {

View File

@@ -79,25 +79,81 @@ pub fn parentNode(self: *DOMTreeWalker) !?*Node {
pub fn firstChild(self: *DOMTreeWalker) !?*Node {
var node = self._current.firstChild();
while (node) |n| {
if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
const filter_result = try self.acceptNode(n);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = n;
return n;
}
node = self.nextSiblingOrNull(n);
if (filter_result == NodeFilter.FILTER_SKIP) {
// Descend into children of this skipped node
if (n.firstChild()) |child| {
node = child;
continue;
}
}
// REJECT or SKIP with no children - find next sibling, walking up if necessary
var current_node = n;
while (true) {
if (current_node.nextSibling()) |sibling| {
node = sibling;
break;
}
// No sibling, go up to parent
const parent = current_node._parent orelse return null;
if (parent == self._current) {
// We've exhausted all children of self._current
return null;
}
current_node = parent;
}
}
return null;
}
pub fn lastChild(self: *DOMTreeWalker) !?*Node {
var node = self._current.lastChild();
while (node) |n| {
if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
const filter_result = try self.acceptNode(n);
if (filter_result == NodeFilter.FILTER_ACCEPT) {
self._current = n;
return n;
}
node = self.previousSiblingOrNull(n);
if (filter_result == NodeFilter.FILTER_SKIP) {
// Descend into children of this skipped node
if (n.lastChild()) |child| {
node = child;
continue;
}
}
// REJECT or SKIP with no children - find previous sibling, walking up if necessary
var current_node = n;
while (true) {
if (current_node.previousSibling()) |sibling| {
node = sibling;
break;
}
// No sibling, go up to parent
const parent = current_node._parent orelse return null;
if (parent == self._current) {
// We've exhausted all children of self._current
return null;
}
current_node = parent;
}
}
return null;
}
@@ -131,15 +187,39 @@ pub fn previousNode(self: *DOMTreeWalker) !?*Node {
var sibling = self.previousSiblingOrNull(node);
while (sibling) |sib| {
node = sib;
var child = self.lastChildOrNull(node);
while (child) |c| {
if (self.isInSubtree(c)) {
node = c;
child = self.lastChildOrNull(node);
} else {
break;
}
// Check if this sibling is rejected before descending into it
const sib_result = try self.acceptNode(node);
if (sib_result == NodeFilter.FILTER_REJECT) {
// Skip this sibling and its descendants entirely
sibling = self.previousSiblingOrNull(node);
continue;
}
// Descend to the deepest last child, but respect FILTER_REJECT
while (true) {
var child = self.lastChildOrNull(node);
// Find the rightmost non-rejected child
while (child) |c| {
if (!self.isInSubtree(c)) break;
const filter_result = try self.acceptNode(c);
if (filter_result == NodeFilter.FILTER_REJECT) {
// Skip this child and try its previous sibling
child = self.previousSiblingOrNull(c);
} else {
// ACCEPT or SKIP - use this child
break;
}
}
if (child == null) break; // No acceptable children
// Descend into this child
node = child.?;
}
if (try self.acceptNode(node) == NodeFilter.FILTER_ACCEPT) {
self._current = node;
return node;

View File

@@ -48,6 +48,8 @@ _location: ?*Location = null,
_ready_state: ReadyState = .loading,
_current_script: ?*Element.Html.Script = null,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty,
// Track IDs that were removed from the map - they might have duplicates in the tree
_removed_ids: std.StringHashMapUnmanaged(void) = .empty,
_active_element: ?*Element = null,
_style_sheets: ?*StyleSheetList = null,
_write_insertion_point: ?*Node = null,
@@ -121,10 +123,15 @@ const CreateElementOptions = struct {
is: ?[]const u8 = null,
};
pub fn createElement(_: *const Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element {
pub fn createElement(self: *Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element {
const node = try page.createElement(null, name, null);
const element = node.as(Element);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
const options = options_ orelse return element;
if (options.is) |is_value| {
try element.setAttribute("is", is_value, page);
@@ -134,8 +141,13 @@ pub fn createElement(_: *const Document, name: []const u8, options_: ?CreateElem
return element;
}
pub fn createElementNS(_: *const Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element {
pub fn createElementNS(self: *Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element {
const node = try page.createElement(namespace, name, null);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
return node.as(Element);
}
@@ -163,9 +175,32 @@ pub fn createAttributeNS(_: *const Document, namespace: []const u8, name: []cons
});
}
pub fn getElementById(self: *const Document, id_: ?[]const u8) ?*Element {
const id = id_ orelse return null;
return self._elements_by_id.get(id);
pub fn getElementById(self: *Document, id: []const u8, page: *Page) ?*Element {
if (id.len == 0) {
return null;
}
if (self._elements_by_id.get(id)) |element| {
return element;
}
//ID was removed but might have duplicates
if (self._removed_ids.remove(id)) {
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
while (tw.next()) |el| {
const element_id = el.getAttributeSafe("id") orelse continue;
if (std.mem.eql(u8, element_id, id)) {
// we ignore this error to keep getElementById easy to call
// if it really failed, then we're out of memory and nothing's
// going to work like it should anyways.
const owned_id = page.dupeString(id) catch return null;
self._elements_by_id.put(page.arena, owned_id, el) catch return null;
return el;
}
}
}
return null;
}
const GetElementsByTagNameResult = union(enum) {
@@ -252,28 +287,53 @@ pub fn getImplementation(_: *const Document) DOMImplementation {
return .{};
}
pub fn createDocumentFragment(_: *const Document, page: *Page) !*Node.DocumentFragment {
return Node.DocumentFragment.init(page);
}
pub fn createComment(_: *const Document, data: []const u8, page: *Page) !*Node {
return page.createComment(data);
}
pub fn createTextNode(_: *const Document, data: []const u8, page: *Page) !*Node {
return page.createTextNode(data);
}
pub fn createCDATASection(self: *const Document, data: []const u8, page: *Page) !*Node {
switch (self._type) {
.html => return error.NotSupported, // cannot create a CDataSection in an HTMLDocument
.xml => return page.createCDATASection(data),
.generic => return page.createCDATASection(data),
pub fn createDocumentFragment(self: *Document, page: *Page) !*Node.DocumentFragment {
const frag = try Node.DocumentFragment.init(page);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(frag.asNode(), self);
}
return frag;
}
pub fn createProcessingInstruction(_: *const Document, target: []const u8, data: []const u8, page: *Page) !*Node {
return page.createProcessingInstruction(target, data);
pub fn createComment(self: *Document, data: []const u8, page: *Page) !*Node {
const node = try page.createComment(data);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
return node;
}
pub fn createTextNode(self: *Document, data: []const u8, page: *Page) !*Node {
const node = try page.createTextNode(data);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
return node;
}
pub fn createCDATASection(self: *Document, data: []const u8, page: *Page) !*Node {
const node = switch (self._type) {
.html => return error.NotSupported, // cannot create a CDataSection in an HTMLDocument
.xml => try page.createCDATASection(data),
.generic => try page.createCDATASection(data),
};
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
return node;
}
pub fn createProcessingInstruction(self: *Document, target: []const u8, data: []const u8, page: *Page) !*Node {
const node = try page.createProcessingInstruction(target, data);
// Track owner document if it's not the main document
if (self != page.document) {
try page.setNodeOwnerDocument(node, self);
}
return node;
}
const Range = @import("Range.zig");
@@ -301,14 +361,26 @@ pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@i
return error.NotSupported;
}
pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?u32, filter: ?DOMTreeWalker.FilterOpts, page: *Page) !*DOMTreeWalker {
const show = what_to_show orelse NodeFilter.SHOW_ALL;
return DOMTreeWalker.init(root, show, filter, page);
pub fn createTreeWalker(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMTreeWalker.FilterOpts, page: *Page) !*DOMTreeWalker {
return DOMTreeWalker.init(root, try whatToShow(what_to_show), filter, page);
}
pub fn createNodeIterator(_: *const Document, root: *Node, what_to_show: ?u32, filter: ?DOMNodeIterator.FilterOpts, page: *Page) !*DOMNodeIterator {
const show = what_to_show orelse NodeFilter.SHOW_ALL;
return DOMNodeIterator.init(root, show, filter, page);
pub fn createNodeIterator(_: *const Document, root: *Node, what_to_show: ?js.Value, filter: ?DOMNodeIterator.FilterOpts, page: *Page) !*DOMNodeIterator {
return DOMNodeIterator.init(root, try whatToShow(what_to_show), filter, page);
}
fn whatToShow(value_: ?js.Value) !u32 {
const value = value_ orelse return 4294967295; // show all when undefined
if (value.isUndefined()) {
// undefined explicitly passed
return 4294967295;
}
if (value.isNull()) {
return 0;
}
return value.toZig(u32);
}
pub fn getReadyState(self: *const Document) []const u8 {
@@ -673,7 +745,17 @@ pub const JsApi = struct {
pub const createEvent = bridge.function(Document.createEvent, .{ .dom_exception = true });
pub const createTreeWalker = bridge.function(Document.createTreeWalker, .{});
pub const createNodeIterator = bridge.function(Document.createNodeIterator, .{});
pub const getElementById = bridge.function(Document.getElementById, .{});
pub const getElementById = bridge.function(_getElementById, .{});
fn _getElementById(self: *Document, value_: ?js.Value, page: *Page) !?*Element {
const value = value_ orelse return null;
if (value.isNull()) {
return self.getElementById("null", page);
}
if (value.isUndefined()) {
return self.getElementById("undefined", page);
}
return self.getElementById(try value.toZig([]const u8), page);
}
pub const querySelector = bridge.function(Document.querySelector, .{ .dom_exception = true });
pub const querySelectorAll = bridge.function(Document.querySelectorAll, .{ .dom_exception = true });
pub const getElementsByTagName = bridge.function(Document.getElementsByTagName, .{});

View File

@@ -71,8 +71,10 @@ pub fn className(_: *const DocumentFragment) []const u8 {
return "[object DocumentFragment]";
}
pub fn getElementById(self: *DocumentFragment, id_: ?[]const u8) ?*Element {
const id = id_ orelse return null;
pub fn getElementById(self: *DocumentFragment, id: []const u8) ?*Element {
if (id.len == 0) {
return null;
}
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
while (tw.next()) |el| {
@@ -156,6 +158,12 @@ pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText,
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
const child = try node_or_text.toNode(page);
// If the new children has already a parent, remove from it.
if (child._parent) |p| {
page.removeNode(p, child, .{ .will_be_reconnected = true });
}
try page.appendNode(parent, child, .{ .child_already_connected = parent_is_connected });
}
}
@@ -233,7 +241,18 @@ pub const JsApi = struct {
pub const constructor = bridge.constructor(DocumentFragment.init, .{});
pub const getElementById = bridge.function(DocumentFragment.getElementById, .{});
pub const getElementById = bridge.function(_getElementById, .{});
fn _getElementById(self: *DocumentFragment, value_: ?js.Value) !?*Element {
const value = value_ orelse return null;
if (value.isNull()) {
return self.getElementById("null");
}
if (value.isUndefined()) {
return self.getElementById("undefined");
}
return self.getElementById(try value.toZig([]const u8));
}
pub const querySelector = bridge.function(DocumentFragment.querySelector, .{ .dom_exception = true });
pub const querySelectorAll = bridge.function(DocumentFragment.querySelectorAll, .{ .dom_exception = true });
pub const children = bridge.accessor(DocumentFragment.getChildren, null, .{});

View File

@@ -70,9 +70,4 @@ pub const JsApi = struct {
pub const name = bridge.accessor(DocumentType.getName, null, .{});
pub const publicId = bridge.accessor(DocumentType.getPublicId, null, .{});
pub const systemId = bridge.accessor(DocumentType.getSystemId, null, .{});
pub const toString = bridge.function(_toString, .{});
fn _toString(self: *const DocumentType) []const u8 {
return self.className();
}
};

View File

@@ -44,6 +44,7 @@ const Element = @This();
pub const DatasetLookup = std.AutoHashMapUnmanaged(*Element, *DOMStringMap);
pub const StyleLookup = std.AutoHashMapUnmanaged(*Element, *CSSStyleProperties);
pub const ClassListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const RelListLookup = std.AutoHashMapUnmanaged(*Element, *collections.DOMTokenList);
pub const ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
pub const AssignedSlotLookup = std.AutoHashMapUnmanaged(*Element, *Html.Slot);
@@ -316,6 +317,20 @@ pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void {
return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page);
}
pub fn setOuterHTML(self: *Element, html: []const u8, page: *Page) !void {
const node = self.asNode();
const parent = node._parent orelse return;
page.domChanged();
if (html.len > 0) {
const fragment = (try Node.DocumentFragment.init(page)).asNode();
try page.parseHtmlAsChildren(fragment, html);
try page.insertAllChildrenBefore(fragment, parent, node);
}
page.removeNode(parent, node, .{ .will_be_reconnected = false });
}
pub fn getInnerHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void {
const dump = @import("../dump.zig");
return dump.children(self.asNode(), .{ .shadow = .skip }, writer, page);
@@ -550,6 +565,17 @@ pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList {
return gop.value_ptr.*;
}
pub fn getRelList(self: *Element, page: *Page) !*collections.DOMTokenList {
const gop = try page._element_rel_lists.getOrPut(page.arena, self);
if (!gop.found_existing) {
gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{
._element = self,
._attribute_name = "rel",
});
}
return gop.value_ptr.*;
}
pub fn getDataset(self: *Element, page: *Page) !*DOMStringMap {
const gop = try page._element_datasets.getOrPut(page.arena, self);
if (!gop.found_existing) {
@@ -966,7 +992,7 @@ pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Pag
var class_names: std.ArrayList([]const u8) = .empty;
var it = std.mem.tokenizeAny(u8, class_name, &std.ascii.whitespace);
while (it.next()) |name| {
try class_names.append(arena, name);
try class_names.append(arena, try page.dupeString(name));
}
return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page);
@@ -978,6 +1004,11 @@ pub fn cloneElement(self: *Element, deep: bool, page: *Page) !*Node {
const node = try page.createElement(namespace_uri, tag_name, self._attributes);
// Allow element-specific types to copy their runtime state
_ = Element.Build.call(node.as(Element), "cloned", .{ self, node.as(Element), page }) catch |err| {
log.err(.dom, "element.clone.failed", .{ .err = err });
};
if (deep) {
var child_it = self.asNode().childrenIterator();
while (child_it.next()) |child| {
@@ -1208,7 +1239,7 @@ pub const JsApi = struct {
return buf.written();
}
pub const outerHTML = bridge.accessor(_outerHTML, null, .{});
pub const outerHTML = bridge.accessor(_outerHTML, Element.setOuterHTML, .{});
fn _outerHTML(self: *Element, page: *Page) ![]const u8 {
var buf = std.Io.Writer.Allocating.init(page.call_arena);
try self.getOuterHTML(&buf.writer, page);

View File

@@ -40,7 +40,7 @@ _prevent_default: bool = false,
_stop_propagation: bool = false,
_stop_immediate_propagation: bool = false,
_event_phase: EventPhase = .none,
_time_stamp: u64 = 0,
_time_stamp: u64,
_needs_retargeting: bool = false,
_isTrusted: bool = false,
@@ -105,9 +105,14 @@ pub fn initEvent(
cancelable: ?bool,
page: *Page,
) !void {
if (self._event_phase != .none) {
return;
}
self._type_string = try String.init(page.arena, event_string, .{});
self._bubbles = bubbles orelse false;
self._cancelable = cancelable orelse false;
self._stop_propagation = false;
}
pub fn as(self: *Event, comptime T: type) *T {
@@ -176,6 +181,22 @@ pub fn getDefaultPrevented(self: *const Event) bool {
return self._prevent_default;
}
pub fn getReturnValue(self: *const Event) bool {
return !self._prevent_default;
}
pub fn setReturnValue(self: *Event, v: bool) void {
self._prevent_default = !v;
}
pub fn getCancelBubble(self: *const Event) bool {
return self._stop_propagation;
}
pub fn setCancelBubble(self: *Event) void {
self.stopPropagation();
}
pub fn getEventPhase(self: *const Event) u8 {
return @intFromEnum(self._event_phase);
}
@@ -372,6 +393,7 @@ pub const JsApi = struct {
pub const cancelable = bridge.accessor(Event.getCancelable, null, .{});
pub const composed = bridge.accessor(Event.getComposed, null, .{});
pub const target = bridge.accessor(Event.getTarget, null, .{});
pub const srcElement = bridge.accessor(Event.getTarget, null, .{});
pub const currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{});
pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{});
pub const defaultPrevented = bridge.accessor(Event.getDefaultPrevented, null, .{});
@@ -382,6 +404,10 @@ pub const JsApi = struct {
pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{});
pub const composedPath = bridge.function(Event.composedPath, .{});
pub const initEvent = bridge.function(Event.initEvent, .{});
// deprecated
pub const returnValue = bridge.accessor(Event.getReturnValue, Event.setReturnValue, .{});
// deprecated
pub const cancelBubble = bridge.accessor(Event.getCancelBubble, Event.setCancelBubble, .{});
// Event phase constants
pub const NONE = bridge.property(@intFromEnum(EventPhase.none));

View File

@@ -167,7 +167,6 @@ pub const JsApi = struct {
pub const dispatchEvent = bridge.function(EventTarget.dispatchEvent, .{});
pub const addEventListener = bridge.function(EventTarget.addEventListener, .{});
pub const removeEventListener = bridge.function(EventTarget.removeEventListener, .{});
pub const toString = bridge.function(EventTarget.toString, .{});
};
const testing = @import("../../testing.zig");

View File

@@ -74,30 +74,76 @@ pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body {
}
pub fn getTitle(self: *HTMLDocument, page: *Page) ![]const u8 {
const head = self.getHead() orelse return "";
var it = head.asNode().childrenIterator();
while (it.next()) |node| {
if (node.is(Element.Html.Title)) |title| {
var buf = std.Io.Writer.Allocating.init(page.call_arena);
try title.asElement().getInnerText(&buf.writer);
return buf.written();
// Search the entire document for the first <title> element
const root = self._proto.getDocumentElement() orelse return "";
const title_element = blk: {
var walker = @import("TreeWalker.zig").Full.init(root.asNode(), .{});
while (walker.next()) |node| {
if (node.is(Element.Html.Title)) |title| {
break :blk title;
}
}
return "";
};
var buf = std.Io.Writer.Allocating.init(page.call_arena);
try title_element.asNode().getTextContent(&buf.writer);
const text = buf.written();
if (text.len == 0) {
return "";
}
var started = false;
var in_whitespace = false;
var result: std.ArrayList(u8) = .empty;
try result.ensureTotalCapacity(page.call_arena, text.len);
for (text) |c| {
const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C';
if (is_ascii_ws) {
if (started) {
in_whitespace = true;
}
} else {
if (in_whitespace) {
result.appendAssumeCapacity(' ');
in_whitespace = false;
}
result.appendAssumeCapacity(c);
started = true;
}
}
return "";
return result.items;
}
pub fn setTitle(self: *HTMLDocument, title: []const u8, page: *Page) !void {
const head = self.getHead() orelse return;
// Find existing title element in head
var it = head.asNode().childrenIterator();
while (it.next()) |node| {
if (node.is(Element.Html.Title)) |title_element| {
return title_element.asElement().replaceChildren(&.{.{ .text = title }}, page);
// Replace children, but don't create text node for empty string
if (title.len == 0) {
return title_element.asElement().replaceChildren(&.{}, page);
} else {
return title_element.asElement().replaceChildren(&.{.{ .text = title }}, page);
}
}
}
// No title element found, create one
const title_node = try page.createElement(null, "title", null);
const title_element = title_node.as(Element);
try title_element.replaceChildren(&.{.{ .text = title }}, page);
// Only add text if non-empty
if (title.len > 0) {
try title_element.replaceChildren(&.{.{ .text = title }}, page);
}
_ = try head.asNode().appendChild(title_node, page);
}

View File

@@ -46,6 +46,9 @@ _parent: ?*Node = null,
_children: ?*Children = null,
_child_link: LinkedList.Node = .{},
// Lookup for nodes that have a different owner document than page.document
pub const OwnerDocumentLookup = std.AutoHashMapUnmanaged(*Node, *Document);
pub const Type = union(enum) {
cdata: *CData,
element: *Element,
@@ -201,6 +204,10 @@ fn validateNodeInsertion(parent: *Node, node: *Node) !void {
if (node.contains(parent)) {
return error.HierarchyError;
}
if (node._type == .attribute) {
return error.HierarchyError;
}
}
pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
@@ -217,10 +224,11 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
// then we can remove + add a bit more efficiently (we don't have to fully
// disconnect then reconnect)
const child_connected = child.isConnected();
// Check if we're adopting the node to a different document
const child_root = child.getRootNode(null);
const parent_root = self.getRootNode(null);
const adopting_to_new_document = child_connected and child_root != parent_root;
const child_owner = child.ownerDocument(page);
const parent_owner = self.ownerDocument(page) orelse self.as(Document);
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
if (child._parent) |parent| {
// we can signal removeNode that the child will remain connected
@@ -228,6 +236,11 @@ pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
page.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() });
}
// Adopt the node tree if moving between documents
if (adopting_to_new_document) {
try page.adoptNodeTree(child, parent_owner);
}
try page.appendNode(self, child, .{
.child_already_connected = child_connected,
.adopting_to_new_document = adopting_to_new_document,
@@ -427,8 +440,13 @@ pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document {
return current._type.document;
}
// Otherwise, this is a detached node. The owner is the document that
// created it. For now, we only have one document.
// Otherwise, this is a detached node. Check if it has a specific owner
// document registered (for nodes created via non-main documents).
if (page._node_owner_documents.get(@constCast(self))) |owner| {
return owner;
}
// Default to the main document for detached nodes without a specific owner.
return page.document;
}
@@ -457,6 +475,21 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page
return self.appendChild(new_node, page);
};
// special case: if nodes are the same, ignore the change.
if (new_node == ref_node_) {
page.domChanged();
if (page.hasMutationObservers()) {
const parent = new_node._parent.?;
const previous_sibling = new_node.previousSibling();
const next_sibling = new_node.nextSibling();
const replaced = [_]*Node{new_node};
page.childListChange(parent, &replaced, &replaced, previous_sibling, next_sibling);
}
return new_node;
}
if (ref_node._parent == null or ref_node._parent.? != self) {
return error.NotFound;
}
@@ -469,10 +502,11 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page
try validateNodeInsertion(self, new_node);
const child_already_connected = new_node.isConnected();
// Check if we're adopting the node to a different document
const child_root = new_node.getRootNode(null);
const parent_root = self.getRootNode(null);
const adopting_to_new_document = child_already_connected and child_root != parent_root;
const child_owner = new_node.ownerDocument(page);
const parent_owner = self.ownerDocument(page) orelse self.as(Document);
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
page.domChanged();
const will_be_reconnected = self.isConnected();
@@ -480,6 +514,11 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page
page.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
}
// Adopt the node tree if moving between documents
if (adopting_to_new_document) {
try page.adoptNodeTree(new_node, parent_owner);
}
try page.insertNodeRelative(
self,
new_node,
@@ -501,7 +540,13 @@ pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page
try validateNodeInsertion(self, new_child);
_ = try self.insertBefore(new_child, old_child, page);
page.removeNode(self, old_child, .{ .will_be_reconnected = false });
// Special case: if we replace a node by itself, we don't remove it.
// insertBefore is an noop in this case.
if (new_child != old_child) {
page.removeNode(self, old_child, .{ .will_be_reconnected = false });
}
return old_child;
}
@@ -826,11 +871,14 @@ pub const JsApi = struct {
pub const ATTRIBUTE_NODE = bridge.property(2);
pub const TEXT_NODE = bridge.property(3);
pub const CDATA_SECTION_NODE = bridge.property(4);
pub const ENTITY_REFERENCE_NODE = bridge.property(5);
pub const ENTITY_NODE = bridge.property(6);
pub const PROCESSING_INSTRUCTION_NODE = bridge.property(7);
pub const COMMENT_NODE = bridge.property(8);
pub const DOCUMENT_NODE = bridge.property(9);
pub const DOCUMENT_TYPE_NODE = bridge.property(10);
pub const DOCUMENT_FRAGMENT_NODE = bridge.property(11);
pub const NOTATION_NODE = bridge.property(12);
pub const DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01);
pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02);
@@ -886,11 +934,6 @@ pub const JsApi = struct {
pub const getRootNode = bridge.function(Node.getRootNode, .{});
pub const isEqualNode = bridge.function(Node.isEqualNode, .{});
pub const toString = bridge.function(_toString, .{});
fn _toString(self: *const Node) []const u8 {
return self.className();
}
fn _baseURI(_: *Node, page: *const Page) []const u8 {
return page.base();
}

View File

@@ -37,22 +37,35 @@ pub fn init(page: *Page) !*Range {
}
pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
if (offset > node.getLength()) {
return error.IndexSizeError;
}
self._proto._start_container = node;
self._proto._start_offset = offset;
// If start is now after end, collapse to start
if (self._proto.isStartAfterEnd()) {
// If start is now after end, or nodes are in different trees, collapse to start
const end_root = self._proto._end_container.getRootNode(null);
const start_root = node.getRootNode(null);
if (end_root != start_root or self._proto.isStartAfterEnd()) {
self._proto._end_container = self._proto._start_container;
self._proto._end_offset = self._proto._start_offset;
}
}
pub fn setEnd(self: *Range, node: *Node, offset: u32) !void {
// Validate offset
if (offset > node.getLength()) {
return error.IndexSizeError;
}
self._proto._end_container = node;
self._proto._end_offset = offset;
// If end is now before start, collapse to end
if (self._proto.isStartAfterEnd()) {
// If end is now before start, or nodes are in different trees, collapse to end
const start_root = self._proto._start_container.getRootNode(null);
const end_root = node.getRootNode(null);
if (start_root != end_root or self._proto.isStartAfterEnd()) {
self._proto._start_container = self._proto._end_container;
self._proto._start_offset = self._proto._end_offset;
}
@@ -105,6 +118,181 @@ pub fn collapse(self: *Range, to_start: ?bool) void {
}
}
pub fn detach(_: *Range) void {
// Legacy no-op method kept for backwards compatibility
// Modern spec: "The detach() method must do nothing."
}
pub fn compareBoundaryPoints(self: *const Range, how_raw: i32, source_range: *const Range) !i16 {
// Convert how parameter per WebIDL unsigned short conversion
// This handles negative numbers and out-of-range values
const how_mod = @mod(how_raw, 65536);
const how: u16 = if (how_mod < 0) @intCast(@as(i32, how_mod) + 65536) else @intCast(how_mod);
// If how is not one of 0, 1, 2, or 3, throw NotSupportedError
if (how > 3) {
return error.NotSupported;
}
// If the two ranges' root is different, throw WrongDocumentError
const this_root = self._proto._start_container.getRootNode(null);
const source_root = source_range._proto._start_container.getRootNode(null);
if (this_root != source_root) {
return error.WrongDocument;
}
// Determine which boundary points to compare based on how parameter
const result = switch (how) {
0 => AbstractRange.compareBoundaryPoints( // START_TO_START
self._proto._start_container,
self._proto._start_offset,
source_range._proto._start_container,
source_range._proto._start_offset,
),
1 => AbstractRange.compareBoundaryPoints( // START_TO_END
self._proto._start_container,
self._proto._start_offset,
source_range._proto._end_container,
source_range._proto._end_offset,
),
2 => AbstractRange.compareBoundaryPoints( // END_TO_END
self._proto._end_container,
self._proto._end_offset,
source_range._proto._end_container,
source_range._proto._end_offset,
),
3 => AbstractRange.compareBoundaryPoints( // END_TO_START
self._proto._end_container,
self._proto._end_offset,
source_range._proto._start_container,
source_range._proto._start_offset,
),
else => unreachable,
};
return switch (result) {
.before => -1,
.equal => 0,
.after => 1,
};
}
pub fn comparePoint(self: *const Range, node: *Node, offset: u32) !i16 {
if (offset > node.getLength()) {
return error.IndexSizeError;
}
// Check if node is in a different tree than the range
const node_root = node.getRootNode(null);
const start_root = self._proto._start_container.getRootNode(null);
if (node_root != start_root) {
return error.WrongDocument;
}
// Compare point with start boundary
const cmp_start = AbstractRange.compareBoundaryPoints(
node,
offset,
self._proto._start_container,
self._proto._start_offset,
);
if (cmp_start == .before) {
return -1;
}
const cmp_end = AbstractRange.compareBoundaryPoints(
node,
offset,
self._proto._end_container,
self._proto._end_offset,
);
return if (cmp_end == .after) 1 else 0;
}
pub fn isPointInRange(self: *const Range, node: *Node, offset: u32) !bool {
// If node's root is different from the context object's root, return false
const node_root = node.getRootNode(null);
const start_root = self._proto._start_container.getRootNode(null);
if (node_root != start_root) {
return false;
}
if (node._type == .document_type) {
return error.InvalidNodeType;
}
// If offset is greater than node's length, throw IndexSizeError
if (offset > node.getLength()) {
return error.IndexSizeError;
}
// If (node, offset) is before start or after end, return false
const cmp_start = AbstractRange.compareBoundaryPoints(
node,
offset,
self._proto._start_container,
self._proto._start_offset,
);
if (cmp_start == .before) {
return false;
}
const cmp_end = AbstractRange.compareBoundaryPoints(
node,
offset,
self._proto._end_container,
self._proto._end_offset,
);
return cmp_end != .after;
}
pub fn intersectsNode(self: *const Range, node: *Node) bool {
// If node's root is different from the context object's root, return false
const node_root = node.getRootNode(null);
const start_root = self._proto._start_container.getRootNode(null);
if (node_root != start_root) {
return false;
}
// Let parent be node's parent
const parent = node.parentNode() orelse {
// If parent is null, return true
return true;
};
// Let offset be node's index
const offset = parent.getChildIndex(node) orelse {
// Should not happen if node has a parent
return false;
};
// If (parent, offset) is before end and (parent, offset + 1) is after start, return true
const before_end = AbstractRange.compareBoundaryPoints(
parent,
offset,
self._proto._end_container,
self._proto._end_offset,
);
const after_start = AbstractRange.compareBoundaryPoints(
parent,
offset + 1,
self._proto._start_container,
self._proto._start_offset,
);
if (before_end == .before and after_start == .after) {
return true;
}
// Return false
return false;
}
pub fn cloneRange(self: *const Range, page: *Page) !*Range {
const clone = try page._factory.abstractRange(Range{ ._proto = undefined }, page);
clone._proto._end_offset = self._proto._end_offset;
@@ -308,24 +496,35 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
};
// Constants for compareBoundaryPoints
pub const START_TO_START = bridge.property(0);
pub const START_TO_END = bridge.property(1);
pub const END_TO_END = bridge.property(2);
pub const END_TO_START = bridge.property(3);
pub const constructor = bridge.constructor(Range.init, .{});
pub const setStart = bridge.function(Range.setStart, .{});
pub const setEnd = bridge.function(Range.setEnd, .{});
pub const setStartBefore = bridge.function(Range.setStartBefore, .{});
pub const setStartAfter = bridge.function(Range.setStartAfter, .{});
pub const setEndBefore = bridge.function(Range.setEndBefore, .{});
pub const setEndAfter = bridge.function(Range.setEndAfter, .{});
pub const selectNode = bridge.function(Range.selectNode, .{});
pub const setStart = bridge.function(Range.setStart, .{ .dom_exception = true });
pub const setEnd = bridge.function(Range.setEnd, .{ .dom_exception = true });
pub const setStartBefore = bridge.function(Range.setStartBefore, .{ .dom_exception = true });
pub const setStartAfter = bridge.function(Range.setStartAfter, .{ .dom_exception = true });
pub const setEndBefore = bridge.function(Range.setEndBefore, .{ .dom_exception = true });
pub const setEndAfter = bridge.function(Range.setEndAfter, .{ .dom_exception = true });
pub const selectNode = bridge.function(Range.selectNode, .{ .dom_exception = true });
pub const selectNodeContents = bridge.function(Range.selectNodeContents, .{});
pub const collapse = bridge.function(Range.collapse, .{});
pub const cloneRange = bridge.function(Range.cloneRange, .{});
pub const insertNode = bridge.function(Range.insertNode, .{});
pub const deleteContents = bridge.function(Range.deleteContents, .{});
pub const cloneContents = bridge.function(Range.cloneContents, .{});
pub const extractContents = bridge.function(Range.extractContents, .{});
pub const surroundContents = bridge.function(Range.surroundContents, .{});
pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{});
pub const toString = bridge.function(Range.toString, .{});
pub const collapse = bridge.function(Range.collapse, .{ .dom_exception = true });
pub const detach = bridge.function(Range.detach, .{});
pub const compareBoundaryPoints = bridge.function(Range.compareBoundaryPoints, .{ .dom_exception = true });
pub const comparePoint = bridge.function(Range.comparePoint, .{ .dom_exception = true });
pub const isPointInRange = bridge.function(Range.isPointInRange, .{ .dom_exception = true });
pub const intersectsNode = bridge.function(Range.intersectsNode, .{});
pub const cloneRange = bridge.function(Range.cloneRange, .{ .dom_exception = true });
pub const insertNode = bridge.function(Range.insertNode, .{ .dom_exception = true });
pub const deleteContents = bridge.function(Range.deleteContents, .{ .dom_exception = true });
pub const cloneContents = bridge.function(Range.cloneContents, .{ .dom_exception = true });
pub const extractContents = bridge.function(Range.extractContents, .{ .dom_exception = true });
pub const surroundContents = bridge.function(Range.surroundContents, .{ .dom_exception = true });
pub const createContextualFragment = bridge.function(Range.createContextualFragment, .{ .dom_exception = true });
pub const toString = bridge.function(Range.toString, .{ .dom_exception = true });
};
const testing = @import("../../testing.zig");

View File

@@ -39,6 +39,7 @@ _proto: *DocumentFragment,
_mode: Mode,
_host: *Element,
_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
_removed_ids: std.StringHashMapUnmanaged(void) = .{},
pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
return page._factory.documentFragment(ShadowRoot{
@@ -72,9 +73,34 @@ pub fn getHost(self: *const ShadowRoot) *Element {
return self._host;
}
pub fn getElementById(self: *ShadowRoot, id_: ?[]const u8) ?*Element {
const id = id_ orelse return null;
return self._elements_by_id.get(id);
pub fn getElementById(self: *ShadowRoot, id: []const u8, page: *Page) ?*Element {
if (id.len == 0) {
return null;
}
// Fast path: ID is in the map
if (self._elements_by_id.get(id)) |element| {
return element;
}
// Slow path: ID was removed but might have duplicates
if (self._removed_ids.remove(id)) {
// Do a tree walk to find another element with this ID
var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
while (tw.next()) |el| {
const element_id = el.getAttributeSafe("id") orelse continue;
if (std.mem.eql(u8, element_id, id)) {
// we ignore this error to keep getElementById easy to call
// if it really failed, then we're out of memory and nothing's
// going to work like it should anyways.
const owned_id = page.dupeString(id) catch return null;
self._elements_by_id.put(page.arena, owned_id, el) catch return null;
return el;
}
}
}
return null;
}
pub const JsApi = struct {
@@ -88,7 +114,17 @@ pub const JsApi = struct {
pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
pub const getElementById = bridge.function(ShadowRoot.getElementById, .{});
pub const getElementById = bridge.function(_getElementById, .{});
fn _getElementById(self: *ShadowRoot, value_: ?js.Value, page: *Page) !?*Element {
const value = value_ orelse return null;
if (value.isNull()) {
return self.getElementById("null", page);
}
if (value.isUndefined()) {
return self.getElementById("undefined", page);
}
return self.getElementById(try value.toZig([]const u8), page);
}
};
const testing = @import("../../testing.zig");

View File

@@ -28,7 +28,6 @@ const Mode = enum {
tag,
tag_name,
class_name,
name,
all_elements,
child_elements,
child_tag,
@@ -44,7 +43,6 @@ _data: union(Mode) {
tag: NodeLive(.tag),
tag_name: NodeLive(.tag_name),
class_name: NodeLive(.class_name),
name: NodeLive(.name),
all_elements: NodeLive(.all_elements),
child_elements: NodeLive(.child_elements),
child_tag: NodeLive(.child_tag),
@@ -79,7 +77,6 @@ pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
.tag => |*impl| .{ .tag = impl._tw.clone() },
.tag_name => |*impl| .{ .tag_name = impl._tw.clone() },
.class_name => |*impl| .{ .class_name = impl._tw.clone() },
.name => |*impl| .{ .name = impl._tw.clone() },
.all_elements => |*impl| .{ .all_elements = impl._tw.clone() },
.child_elements => |*impl| .{ .child_elements = impl._tw.clone() },
.child_tag => |*impl| .{ .child_tag = impl._tw.clone() },
@@ -98,7 +95,6 @@ pub const Iterator = GenericIterator(struct {
tag: TreeWalker.FullExcludeSelf,
tag_name: TreeWalker.FullExcludeSelf,
class_name: TreeWalker.FullExcludeSelf,
name: TreeWalker.FullExcludeSelf,
all_elements: TreeWalker.FullExcludeSelf,
child_elements: TreeWalker.Children,
child_tag: TreeWalker.Children,
@@ -113,7 +109,6 @@ pub const Iterator = GenericIterator(struct {
.tag => |*impl| impl.nextTw(&self.tw.tag),
.tag_name => |*impl| impl.nextTw(&self.tw.tag_name),
.class_name => |*impl| impl.nextTw(&self.tw.class_name),
.name => |*impl| impl.nextTw(&self.tw.name),
.all_elements => |*impl| impl.nextTw(&self.tw.all_elements),
.child_elements => |*impl| impl.nextTw(&self.tw.child_elements),
.child_tag => |*impl| impl.nextTw(&self.tw.child_tag),

View File

@@ -26,11 +26,13 @@ const Node = @import("../Node.zig");
const ChildNodes = @import("ChildNodes.zig");
const RadioNodeList = @import("RadioNodeList.zig");
const SelectorList = @import("../selector/List.zig");
const NodeLive = @import("node_live.zig").NodeLive;
const Mode = enum {
child_nodes,
selector_list,
radio_node_list,
name,
};
const NodeList = @This();
@@ -39,6 +41,7 @@ data: union(Mode) {
child_nodes: *ChildNodes,
selector_list: *SelectorList,
radio_node_list: *RadioNodeList,
name: NodeLive(.name),
},
pub fn length(self: *NodeList, page: *Page) !u32 {
@@ -46,6 +49,7 @@ pub fn length(self: *NodeList, page: *Page) !u32 {
.child_nodes => |impl| impl.length(page),
.selector_list => |impl| @intCast(impl.getLength()),
.radio_node_list => |impl| impl.getLength(),
.name => |*impl| impl.length(page),
};
}
@@ -54,6 +58,7 @@ pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node {
.child_nodes => |impl| impl.getAtIndex(index, page),
.selector_list => |impl| impl.getAtIndex(index),
.radio_node_list => |impl| impl.getAtIndex(index, page),
.name => |*impl| if (impl.getAtIndex(index, page)) |el| el.asNode() else null,
};
}

View File

@@ -171,7 +171,7 @@ pub fn NodeLive(comptime mode: Mode) type {
}
pub fn getByName(self: *Self, name: []const u8, page: *Page) ?*Element {
if (page.document.getElementById(name)) |element| {
if (page.document.getElementById(name, page)) |element| {
const node = element.asNode();
if (self._tw.contains(node) and self.matches(node)) {
return element;
@@ -320,12 +320,14 @@ pub fn NodeLive(comptime mode: Mode) type {
}
const HTMLCollection = @import("HTMLCollection.zig");
pub fn runtimeGenericWrap(self: Self, page: *Page) !*HTMLCollection {
const NodeList = @import("NodeList.zig");
pub fn runtimeGenericWrap(self: Self, page: *Page) !if (mode == .name) *NodeList else *HTMLCollection {
const collection = switch (mode) {
.name => return page._factory.create(NodeList{ .data = .{ .name = self } }),
.tag => HTMLCollection{ ._data = .{ .tag = self } },
.tag_name => HTMLCollection{ ._data = .{ .tag_name = self } },
.class_name => HTMLCollection{ ._data = .{ .class_name = self } },
.name => HTMLCollection{ ._data = .{ .name = self } },
.all_elements => HTMLCollection{ ._data = .{ .all_elements = self } },
.child_elements => HTMLCollection{ ._data = .{ .child_elements = self } },
.child_tag => HTMLCollection{ ._data = .{ .child_tag = self } },

View File

@@ -20,6 +20,8 @@ const std = @import("std");
const log = @import("../../../log.zig");
const String = @import("../../../string.zig").String;
const CssParser = @import("../../css/Parser.zig");
const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig");
const Element = @import("../Element.zig");
@@ -149,30 +151,10 @@ pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !vo
}
// Parse and set new properties
// This is a simple parser - a full implementation would use a proper CSS parser
var it = std.mem.splitScalar(u8, text, ';');
var it = CssParser.parseDeclarationsList(text);
while (it.next()) |declaration| {
const trimmed = std.mem.trim(u8, declaration, &std.ascii.whitespace);
if (trimmed.len == 0) continue;
if (std.mem.indexOfScalar(u8, trimmed, ':')) |colon_pos| {
const name = std.mem.trim(u8, trimmed[0..colon_pos], &std.ascii.whitespace);
const value_part = std.mem.trim(u8, trimmed[colon_pos + 1 ..], &std.ascii.whitespace);
var value = value_part;
var priority: ?[]const u8 = null;
// Check for !important
if (std.mem.lastIndexOfScalar(u8, value_part, '!')) |bang_pos| {
const after_bang = std.mem.trim(u8, value_part[bang_pos + 1 ..], &std.ascii.whitespace);
if (std.mem.eql(u8, after_bang, "important")) {
value = std.mem.trimRight(u8, value_part[0..bang_pos], &std.ascii.whitespace);
priority = "important";
}
}
try self.setProperty(name, value, priority, page);
}
const priority: ?[]const u8 = if (declaration.important) "important" else null;
try self.setProperty(declaration.name, declaration.value, priority, page);
}
}

View File

@@ -477,11 +477,19 @@ pub const NamedNodeMap = struct {
return self._list.getAttribute(name, self._element, page);
}
pub fn setByName(self: *const NamedNodeMap, attribute: *Attribute, page: *Page) !?*Attribute {
pub fn set(self: *const NamedNodeMap, attribute: *Attribute, page: *Page) !?*Attribute {
attribute._element = null; // just a requirement of list.putAttribute, it'll re-set it.
return self._list.putAttribute(attribute, self._element, page);
}
pub fn removeByName(self: *const NamedNodeMap, name: []const u8, page: *Page) !?*Attribute {
// this 2-step process (get then delete) isn't efficient. But we don't
// expect this to be called often, and this lets us keep delete straightforward.
const attr = (try self.getByName(name, page)) orelse return null;
try self._list.delete(name, self._element, page);
return attr;
}
pub fn iterator(self: *const NamedNodeMap, page: *Page) !*Iterator {
return .init(.{ .list = self }, page);
}
@@ -510,7 +518,8 @@ pub const NamedNodeMap = struct {
pub const @"[int]" = bridge.indexed(NamedNodeMap.getAtIndex, .{ .null_as_undefined = true });
pub const @"[str]" = bridge.namedIndexed(NamedNodeMap.getByName, null, null, .{ .null_as_undefined = true });
pub const getNamedItem = bridge.function(NamedNodeMap.getByName, .{});
pub const setNamedItem = bridge.function(NamedNodeMap.setByName, .{});
pub const setNamedItem = bridge.function(NamedNodeMap.set, .{});
pub const removeNamedItem = bridge.function(NamedNodeMap.removeByName, .{});
pub const item = bridge.function(_item, .{});
fn _item(self: *const NamedNodeMap, index: i32, page: *Page) !?*Attribute {
// the bridge.indexed handles this, so if we want

View File

@@ -220,7 +220,18 @@ pub const JsApi = struct {
pub const hash = bridge.accessor(Anchor.getHash, Anchor.setHash, .{});
pub const @"type" = bridge.accessor(Anchor.getType, Anchor.setType, .{});
pub const text = bridge.accessor(Anchor.getText, Anchor.setText, .{});
pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true });
pub const toString = bridge.function(Anchor.getHref, .{});
fn _getRelList(self: *Anchor, page: *Page) !?*@import("../../collections.zig").DOMTokenList {
const element = self.asElement();
// relList is only valid for HTML and SVG <a> elements
const namespace = element._namespace;
if (namespace != .html and namespace != .svg) {
return null;
}
return element.getRelList(page);
}
};
const testing = @import("../../../../testing.zig");

View File

@@ -91,7 +91,7 @@ pub fn getForm(self: *Button, page: *Page) ?*Form {
// If form attribute exists, ONLY use that (even if it references nothing)
if (element.getAttributeSafe("form")) |form_id| {
if (page.document.getElementById(form_id)) |form_element| {
if (page.document.getElementById(form_id, page)) |form_element| {
return form_element.is(Form);
}
// form attribute present but invalid - no form owner

View File

@@ -46,14 +46,16 @@ pub const Type = enum {
range,
date,
time,
datetime_local,
@"datetime-local",
month,
week,
color,
pub fn fromString(str: []const u8) Type {
// Longest type name is "datetime-local" at 14 chars
if (str.len > 32) return .text;
if (str.len > 32) {
return .text;
}
var buf: [32]u8 = undefined;
const lower = std.ascii.lowerString(&buf, str);
@@ -61,10 +63,7 @@ pub const Type = enum {
}
pub fn toString(self: Type) []const u8 {
return switch (self) {
.datetime_local => "datetime-local",
else => @tagName(self),
};
return @tagName(self);
}
};
@@ -76,6 +75,7 @@ _checked: bool = false,
_checked_dirty: bool = false,
_input_type: Type = .text,
_selected: bool = false,
_indeterminate: bool = false,
pub fn asElement(self: *Input) *Element {
return self._proto._proto;
@@ -129,6 +129,14 @@ pub fn setChecked(self: *Input, checked: bool, page: *Page) !void {
self._checked_dirty = true;
}
pub fn getIndeterminate(self: *const Input) bool {
return self._indeterminate;
}
pub fn setIndeterminate(self: *Input, value: bool) !void {
self._indeterminate = value;
}
pub fn getDefaultChecked(self: *const Input) bool {
return self._default_checked;
}
@@ -256,7 +264,7 @@ pub fn getForm(self: *Input, page: *Page) ?*Form {
// If form attribute exists, ONLY use that (even if it references nothing)
if (element.getAttributeSafe("form")) |form_id| {
if (page.document.getElementById(form_id)) |form_element| {
if (page.document.getElementById(form_id, page)) |form_element| {
return form_element.is(Form);
}
// form attribute present but invalid - no form owner
@@ -342,6 +350,7 @@ pub const JsApi = struct {
pub const size = bridge.accessor(Input.getSize, Input.setSize, .{});
pub const src = bridge.accessor(Input.getSrc, Input.setSrc, .{});
pub const form = bridge.accessor(Input.getForm, null, .{});
pub const indeterminate = bridge.accessor(Input.getIndeterminate, Input.setIndeterminate, .{});
pub const select = bridge.function(Input.select, .{});
};
@@ -404,6 +413,18 @@ pub const Build = struct {
},
}
}
pub fn cloned(source_element: *Element, cloned_element: *Element, _: *Page) !void {
const source = source_element.as(Input);
const clone = cloned_element.as(Input);
// Copy runtime state from source to clone
clone._value = source._value;
clone._checked = source._checked;
clone._checked_dirty = source._checked_dirty;
clone._selected = source._selected;
clone._indeterminate = source._indeterminate;
}
};
const testing = @import("../../../../testing.zig");

View File

@@ -68,6 +68,16 @@ pub const JsApi = struct {
pub const rel = bridge.accessor(Link.getRel, Link.setRel, .{});
pub const href = bridge.accessor(Link.getHref, Link.setHref, .{});
pub const relList = bridge.accessor(_getRelList, null, .{ .null_as_undefined = true });
fn _getRelList(self: *Link, page: *Page) !?*@import("../../collections.zig").DOMTokenList {
const element = self.asElement();
// relList is only valid for HTML <link> elements, not SVG or MathML
if (element._namespace != .html) {
return null;
}
return element.getRelList(page);
}
};
const testing = @import("../../../../testing.zig");

View File

@@ -219,7 +219,7 @@ pub fn getForm(self: *Select, page: *Page) ?*Form {
// If form attribute exists, ONLY use that (even if it references nothing)
if (element.getAttributeSafe("form")) |form_id| {
if (page.document.getElementById(form_id)) |form_element| {
if (page.document.getElementById(form_id, page)) |form_element| {
return form_element.is(Form);
}
// form attribute present but invalid - no form owner

View File

@@ -90,7 +90,7 @@ pub fn getForm(self: *TextArea, page: *Page) ?*Form {
// If form attribute exists, ONLY use that (even if it references nothing)
if (element.getAttributeSafe("form")) |form_id| {
if (page.document.getElementById(form_id)) |form_element| {
if (page.document.getElementById(form_id, page)) |form_element| {
return form_element.is(Form);
}
// form attribute present but invalid - no form owner

View File

@@ -72,6 +72,7 @@ dependencies = [
"tikv-jemalloc-ctl",
"tikv-jemallocator",
"typed-arena",
"xml5ever",
]
[[package]]
@@ -476,3 +477,13 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "xml5ever"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee3f1e41afb31a75aef076563b0ad3ecc24f5bd9d12a72b132222664eb76b494"
dependencies = [
"log",
"markup5ever",
]

View File

@@ -14,6 +14,7 @@ string_cache = "0.9.0"
typed-arena = "2.0.2"
tikv-jemallocator = {version = "0.6.0", features = ["stats"]}
tikv-jemalloc-ctl = {version = "0.6.0", features = ["stats"]}
xml5ever = "0.35.0"
[profile.release]
lto = true

View File

@@ -16,20 +16,20 @@
// 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/>.
mod types;
mod sink;
mod types;
#[cfg(debug_assertions)]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
use types::*;
use std::cell::Cell;
use std::os::raw::{c_uchar, c_void};
use types::*;
use html5ever::{parse_document, parse_fragment, QualName, LocalName, ns, ParseOpts, Parser};
use html5ever::tendril::{TendrilSink, StrTendril};
use html5ever::interface::tree_builder::QuirksMode;
use html5ever::tendril::{StrTendril, TendrilSink};
use html5ever::{ns, parse_document, parse_fragment, LocalName, ParseOpts, Parser, QualName};
#[no_mangle]
pub extern "C" fn html5ever_parse_document(
@@ -135,13 +135,14 @@ pub extern "C" fn html5ever_parse_fragment(
let bytes = unsafe { std::slice::from_raw_parts(html, len) };
parse_fragment(
sink, Default::default(),
sink,
Default::default(),
QualName::new(None, ns!(html), LocalName::from("body")),
vec![], // attributes
false, // context_element_allows_scripting
vec![], // attributes
false, // context_element_allows_scripting
)
.from_utf8()
.one(bytes);
.from_utf8()
.one(bytes);
}
#[no_mangle]
@@ -182,15 +183,15 @@ pub struct Memory {
#[cfg(debug_assertions)]
#[no_mangle]
pub extern "C" fn html5ever_get_memory_usage() -> Memory {
use tikv_jemalloc_ctl::{stats, epoch};
use tikv_jemalloc_ctl::{epoch, stats};
// many statistics are cached and only updated when the epoch is advanced.
epoch::advance().unwrap();
return Memory{
return Memory {
resident: stats::resident::read().unwrap(),
allocated: stats::allocated::read().unwrap(),
}
};
}
// Streaming parser API
@@ -225,9 +226,8 @@ pub extern "C" fn html5ever_streaming_parser_create(
// SAFETY: We're creating a self-referential structure here.
// The arena is stored in the StreamingParser and lives as long as the parser.
// The sink contains a reference to the arena that's valid for the parser's lifetime.
let arena_ref: &'static typed_arena::Arena<sink::ElementData> = unsafe {
std::mem::transmute(arena.as_ref())
};
let arena_ref: &'static typed_arena::Arena<sink::ElementData> =
unsafe { std::mem::transmute(arena.as_ref()) };
let sink = sink::Sink {
ctx: ctx,
@@ -281,7 +281,8 @@ pub extern "C" fn html5ever_streaming_parser_feed(
// Feed the chunk to the parser
// The Parser implements TendrilSink, so we can call process() on it
let parser = streaming_parser.parser
let parser = streaming_parser
.parser
.downcast_mut::<Parser<sink::Sink>>()
.expect("Invalid parser type");
@@ -304,7 +305,8 @@ pub extern "C" fn html5ever_streaming_parser_finish(parser_ptr: *mut c_void) {
let streaming_parser = unsafe { Box::from_raw(parser_ptr as *mut StreamingParser) };
// Extract and finish the parser
let parser = streaming_parser.parser
let parser = streaming_parser
.parser
.downcast::<Parser<sink::Sink>>()
.expect("Invalid parser type");
@@ -326,3 +328,57 @@ pub extern "C" fn html5ever_streaming_parser_destroy(parser_ptr: *mut c_void) {
let _ = Box::from_raw(parser_ptr as *mut StreamingParser);
}
}
#[no_mangle]
pub extern "C" fn xml5ever_parse_document(
xml: *mut c_uchar,
len: usize,
document: Ref,
ctx: Ref,
create_element_callback: CreateElementCallback,
get_data_callback: GetDataCallback,
append_callback: AppendCallback,
parse_error_callback: ParseErrorCallback,
pop_callback: PopCallback,
create_comment_callback: CreateCommentCallback,
create_processing_instruction: CreateProcessingInstruction,
append_doctype_to_document: AppendDoctypeToDocumentCallback,
add_attrs_if_missing_callback: AddAttrsIfMissingCallback,
get_template_contents_callback: GetTemplateContentsCallback,
remove_from_parent_callback: RemoveFromParentCallback,
reparent_children_callback: ReparentChildrenCallback,
append_before_sibling_callback: AppendBeforeSiblingCallback,
append_based_on_parent_node_callback: AppendBasedOnParentNodeCallback,
) -> () {
if xml.is_null() || len == 0 {
return ();
}
let arena = typed_arena::Arena::new();
let sink = sink::Sink {
ctx: ctx,
arena: &arena,
document: document,
quirks_mode: Cell::new(QuirksMode::NoQuirks),
pop_callback: pop_callback,
append_callback: append_callback,
get_data_callback: get_data_callback,
parse_error_callback: parse_error_callback,
create_element_callback: create_element_callback,
create_comment_callback: create_comment_callback,
create_processing_instruction: create_processing_instruction,
append_doctype_to_document: append_doctype_to_document,
add_attrs_if_missing_callback: add_attrs_if_missing_callback,
get_template_contents_callback: get_template_contents_callback,
remove_from_parent_callback: remove_from_parent_callback,
reparent_children_callback: reparent_children_callback,
append_before_sibling_callback: append_before_sibling_callback,
append_based_on_parent_node_callback: append_based_on_parent_node_callback,
};
let bytes = unsafe { std::slice::from_raw_parts(xml, len) };
xml5ever::driver::parse_document(sink, xml5ever::driver::XmlParseOpts::default())
.from_utf8()
.one(bytes);
}