diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml
index 17c027593..3b29b7b27 100644
--- a/.github/actions/install/action.yml
+++ b/.github/actions/install/action.yml
@@ -5,7 +5,7 @@ inputs:
zig:
description: 'Zig version to install'
required: false
- default: '0.15.1'
+ default: '0.15.2'
arch:
description: 'CPU arch used to select the v8 lib'
required: false
@@ -17,7 +17,7 @@ inputs:
zig-v8:
description: 'zig v8 version to install'
required: false
- default: 'v0.1.33'
+ default: 'v0.1.35'
v8:
description: 'v8 version to install'
required: false
@@ -26,6 +26,10 @@ inputs:
description: 'cache dir to use'
required: false
default: '~/.cache'
+ mode:
+ description: 'debug or release'
+ required: false
+ default: 'debug'
runs:
using: "composite"
@@ -38,7 +42,7 @@ runs:
sudo apt-get update
sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang
- - uses: mlugg/setup-zig@v2
+ - uses: mlugg/setup-zig@v2.0.5
with:
version: ${{ inputs.zig }}
@@ -58,37 +62,26 @@ runs:
wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a
- - name: install v8
+ - name: install v8 release
+ if: ${{ inputs.mode == 'release' }}
shell: bash
run: |
- mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/
- ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a
-
mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a
- - name: Cache libiconv
- id: cache-libiconv
- uses: actions/cache@v4
- env:
- cache-name: cache-libiconv
- with:
- path: ${{ inputs.cache-dir }}/libiconv
- key: vendor/libiconv/libiconv-1.17
-
- - name: download libiconv
- if: ${{ steps.cache-libiconv.outputs.cache-hit != 'true' }}
- shell: bash
- run: make download-libiconv
-
- - name: build libiconv
+ - name: install v8 debug
+ if: ${{ inputs.mode == 'debug' }}
shell: bash
- run: make build-libiconv
+ run: |
+ mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/
+ ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a
- - name: build mimalloc
+ - name: hmtl5ever release
+ if: ${{ inputs.mode == 'release' }}
shell: bash
- run: make install-mimalloc
+ run: zig build -Doptimize=ReleaseFast html5ever
- - name: build netsurf
+ - name: hmtl5ever debug
+ if: ${{ inputs.mode == 'debug' }}
shell: bash
- run: make install-netsurf
+ run: zig build html5ever
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index df16af4c9..0ab034e84 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,6 +36,7 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
+ mode: 'release'
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -74,6 +75,7 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
+ mode: 'release'
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -114,6 +116,7 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
+ mode: 'release'
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -157,6 +160,7 @@ jobs:
with:
os: ${{env.OS}}
arch: ${{env.ARCH}}
+ mode: 'release'
- name: zig build
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index fb295246c..1b8b910b7 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -56,6 +56,8 @@ jobs:
submodules: recursive
- uses: ./.github/actions/install
+ with:
+ mode: 'release'
- name: zig build release
run: zig build -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
@@ -122,7 +124,7 @@ jobs:
needs: zig-build-release
env:
- MAX_MEMORY: 27000
+ MAX_MEMORY: 28000
MAX_AVG_DURATION: 23
LIGHTPANDA_DISABLE_TELEMETRY: true
diff --git a/.github/workflows/zig-fmt.yml b/.github/workflows/zig-fmt.yml
index 2a1fdd527..106e557a1 100644
--- a/.github/workflows/zig-fmt.yml
+++ b/.github/workflows/zig-fmt.yml
@@ -1,7 +1,7 @@
name: zig-fmt
env:
- ZIG_VERSION: 0.15.1
+ ZIG_VERSION: 0.15.2
on:
pull_request:
diff --git a/.gitignore b/.gitignore
index ad9ae7b45..59d6886ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,6 @@
-zig-cache
/.zig-cache/
-zig-out
-/vendor/netsurf/out
-/vendor/libiconv/
+/zig-out/
lightpanda.id
/v8/
+/build/
+/src/html5ever/target/
diff --git a/.gitmodules b/.gitmodules
index 717d079bb..5462f8f0e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,30 +1,9 @@
-[submodule "vendor/netsurf/libwapcaplet"]
- path = vendor/netsurf/libwapcaplet
- url = https://github.com/lightpanda-io/libwapcaplet.git/
-[submodule "vendor/netsurf/libparserutils"]
- path = vendor/netsurf/libparserutils
- url = https://github.com/lightpanda-io/libparserutils.git/
-[submodule "vendor/netsurf/libdom"]
- path = vendor/netsurf/libdom
- url = https://github.com/lightpanda-io/libdom.git/
-[submodule "vendor/netsurf/share/netsurf-buildsystem"]
- path = vendor/netsurf/share/netsurf-buildsystem
- url = https://github.com/lightpanda-io/netsurf-buildsystem.git
-[submodule "vendor/netsurf/libhubbub"]
- path = vendor/netsurf/libhubbub
- url = https://github.com/lightpanda-io/libhubbub.git/
[submodule "tests/wpt"]
path = tests/wpt
url = https://github.com/lightpanda-io/wpt
-[submodule "vendor/mimalloc"]
- path = vendor/mimalloc
- url = https://github.com/microsoft/mimalloc.git/
[submodule "vendor/nghttp2"]
path = vendor/nghttp2
url = https://github.com/nghttp2/nghttp2.git
-[submodule "vendor/mbedtls"]
- path = vendor/mbedtls
- url = https://github.com/Mbed-TLS/mbedtls.git
[submodule "vendor/zlib"]
path = vendor/zlib
url = https://github.com/madler/zlib.git
diff --git a/Dockerfile b/Dockerfile
index bcb613f7f..a405a057c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,10 @@
FROM debian:stable
ARG MINISIG=0.12
-ARG ZIG=0.15.1
+ARG ZIG=0.15.2
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
ARG V8=14.0.365.4
-ARG ZIG_V8=v0.1.33
+ARG ZIG_V8=v0.1.35
ARG TARGETPLATFORM
RUN apt-get update -yq && \
@@ -40,9 +40,7 @@ WORKDIR /browser
RUN git submodule init && \
git submodule update --recursive
-RUN make install-libiconv && \
- make install-netsurf && \
- make install-mimalloc
+RUN zig build -Doptimize=ReleaseFast html5ever
# download and install v8
RUN case $TARGETPLATFORM in \
diff --git a/Makefile b/Makefile
index b0ae69015..3f79e1fa2 100644
--- a/Makefile
+++ b/Makefile
@@ -96,9 +96,16 @@ wpt-summary:
@printf "\e[36mBuilding wpt...\e[0m\n"
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
-## Test
+## Test - `grep` is used to filter out the huge compile command on build
+ifeq ($(OS), macos)
test:
- @TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all
+ @script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' 2>&1 \
+ | grep --line-buffered -v "^/.*zig test -freference-trace"
+else
+test:
+ @script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \
+ | grep --line-buffered -v "^/.*zig test -freference-trace"
+endif
## Run demo/runner end to end tests
end2end:
@@ -120,128 +127,17 @@ build-v8:
# Install and build required dependencies commands
# ------------
-.PHONY: install-submodule
-.PHONY: install-libiconv
-.PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev
-.PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc
-.PHONY: install-dev install
+.PHONY: install install-dev
## Install and build dependencies for release
-install: install-submodule install-libiconv install-netsurf install-mimalloc
+install: install-submodule
## Install and build dependencies for dev
-install-dev: install-submodule install-libiconv install-netsurf-dev install-mimalloc-dev
-
-install-netsurf-dev: _install-netsurf
-install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
-
-install-netsurf: _install-netsurf
-install-netsurf: OPTCFLAGS := -DNDEBUG
-
-BC_NS := $(BC)vendor/netsurf/out/$(OS)-$(ARCH)
-ICONV := $(BC)vendor/libiconv/out/$(OS)-$(ARCH)
-# TODO: add Linux iconv path (I guess it depends on the distro)
-# TODO: this way of linking libiconv is not ideal. We should have a more generic way
-# and stick to a specif version. Maybe build from source. Anyway not now.
-_install-netsurf: clean-netsurf
- @printf "\e[36mInstalling NetSurf...\e[0m\n" && \
- ls $(ICONV)/lib/libiconv.a 1> /dev/null || (printf "\e[33mERROR: you need to execute 'make install-libiconv'\e[0m\n"; exit 1;) && \
- mkdir -p $(BC_NS) && \
- cp -R vendor/netsurf/share $(BC_NS) && \
- export PREFIX=$(BC_NS) && \
- export OPTLDFLAGS="-L$(ICONV)/lib" && \
- export OPTCFLAGS="$(OPTCFLAGS) -I$(ICONV)/include" && \
- printf "\e[33mInstalling libwapcaplet...\e[0m\n" && \
- cd vendor/netsurf/libwapcaplet && \
- BUILDDIR=$(BC_NS)/build/libwapcaplet make install && \
- cd ../libparserutils && \
- printf "\e[33mInstalling libparserutils...\e[0m\n" && \
- BUILDDIR=$(BC_NS)/build/libparserutils make install && \
- cd ../libhubbub && \
- printf "\e[33mInstalling libhubbub...\e[0m\n" && \
- BUILDDIR=$(BC_NS)/build/libhubbub make install && \
- rm src/treebuilder/autogenerated-element-type.c && \
- cd ../libdom && \
- printf "\e[33mInstalling libdom...\e[0m\n" && \
- BUILDDIR=$(BC_NS)/build/libdom make install && \
- printf "\e[33mRunning libdom example...\e[0m\n" && \
- cd examples && \
- $(ZIG) cc \
- -I$(ICONV)/include \
- -I$(BC_NS)/include \
- -L$(ICONV)/lib \
- -L$(BC_NS)/lib \
- -liconv \
- -ldom \
- -lhubbub \
- -lparserutils \
- -lwapcaplet \
- -o a.out \
- dom-structure-dump.c \
- $(ICONV)/lib/libiconv.a && \
- ./a.out > /dev/null && \
- rm a.out && \
- printf "\e[36mDone NetSurf $(OS)\e[0m\n"
-
-clean-netsurf:
- @printf "\e[36mCleaning NetSurf build...\e[0m\n" && \
- rm -Rf $(BC_NS)
-
-test-netsurf:
- @printf "\e[36mTesting NetSurf...\e[0m\n" && \
- export PREFIX=$(BC_NS) && \
- export LDFLAGS="-L$(ICONV)/lib -L$(BC_NS)/lib" && \
- export CFLAGS="-I$(ICONV)/include -I$(BC_NS)/include" && \
- cd vendor/netsurf/libdom && \
- BUILDDIR=$(BC_NS)/build/libdom make test
-
-download-libiconv:
-ifeq ("$(wildcard vendor/libiconv/libiconv-1.17)","")
- @mkdir -p vendor/libiconv
- @cd vendor/libiconv && \
- curl -L https://github.com/lightpanda-io/libiconv/releases/download/1.17/libiconv-1.17.tar.gz | tar -xvzf -
-endif
-
-build-libiconv: clean-libiconv
- @cd vendor/libiconv/libiconv-1.17 && \
- ./configure --prefix=$(ICONV) --enable-static && \
- make && make install
-
-install-libiconv: download-libiconv build-libiconv
-
-clean-libiconv:
-ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
- @cd vendor/libiconv/libiconv-1.17 && \
- make clean
-endif
+install-dev: install-submodule
data:
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
-.PHONY: _build_mimalloc
-
-MIMALLOC := $(BC)vendor/mimalloc/out/$(OS)-$(ARCH)
-_build_mimalloc: clean-mimalloc
- @mkdir -p $(MIMALLOC)/build && \
- cd $(MIMALLOC)/build && \
- cmake -DMI_BUILD_SHARED=OFF -DMI_BUILD_OBJECT=OFF -DMI_BUILD_TESTS=OFF -DMI_OVERRIDE=OFF $(OPTS) ../../.. && \
- make && \
- mkdir -p $(MIMALLOC)/lib
-
-install-mimalloc-dev: _build_mimalloc
-install-mimalloc-dev: OPTS=-DCMAKE_BUILD_TYPE=Debug
-install-mimalloc-dev:
- @cd $(MIMALLOC) && \
- mv build/libmimalloc-debug.a lib/libmimalloc.a
-
-install-mimalloc: _build_mimalloc
-install-mimalloc:
- @cd $(MIMALLOC) && \
- mv build/libmimalloc.a lib/libmimalloc.a
-
-clean-mimalloc:
- @rm -Rf $(MIMALLOC)/build
-
## Init and update git submodule
install-submodule:
@git submodule init && \
diff --git a/README.md b/README.md
index a1009e7f1..5e25926ab 100644
--- a/README.md
+++ b/README.md
@@ -140,13 +140,14 @@ You may still encounter errors or crashes. Please open an issue with specifics i
Here are the key features we have implemented:
-- [x] HTTP loader (based on Libcurl)
-- [x] HTML parser and DOM tree (based on Netsurf libs)
-- [x] Javascript support (v8)
+- [x] HTTP loader ([Libcurl](https://curl.se/libcurl/))
+- [x] HTML parser ([html5ever](https://github.com/servo/html5ever))
+- [x] DOM tree
+- [x] Javascript support ([v8](https://v8.dev/))
- [x] DOM APIs
- [x] Ajax
- [x] XHR API
- - [x] Fetch API (polyfill)
+ - [x] Fetch API
- [x] DOM dump
- [x] CDP/websockets server
- [x] Click
@@ -164,7 +165,7 @@ You can also follow the progress of our Javascript support in our dedicated [zig
### Prerequisites
-Lightpanda is written with [Zig](https://ziglang.org/) `0.15.1`. You have to
+Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2`. You have to
install it with the right version in order to build the project.
Lightpanda also depends on
@@ -214,37 +215,15 @@ To init or update the submodules in the `vendor/` directory:
make install-submodule
```
-**iconv**
+**html5ever**
-libiconv is an internationalization library used by Netsurf.
+[html5ver](https://github.com/servo/html5ever) is high-performance browser-grade HTML5 parser.
```
-make install-libiconv
+zig build html5ever
```
-**Netsurf libs**
-
-Netsurf libs are used for HTML parsing and DOM tree generation.
-
-```
-make install-netsurf
-```
-
-For dev env, use `make install-netsurf-dev`.
-
-**Mimalloc**
-
-Mimalloc is used as a C memory allocator.
-
-```
-make install-mimalloc
-```
-
-For dev env, use `make install-mimalloc-dev`.
-
-Note: when Mimalloc is built in dev mode, you can dump memory stats with the
-env var `MIMALLOC_SHOW_STATS=1`. See
-[https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html).
+For a release build, use `zig build -Doptimize=ReleaseFast html5ever`.
**v8**
diff --git a/build.zig b/build.zig
index 3437dfad0..0070e5769 100644
--- a/build.zig
+++ b/build.zig
@@ -23,7 +23,7 @@ const Build = std.Build;
/// Do not rename this constant. It is scanned by some scripts to determine
/// which zig version to install.
-const recommended_zig_version = "0.15.1";
+const recommended_zig_version = "0.15.2";
pub fn build(b: *Build) !void {
switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {
@@ -39,6 +39,9 @@ pub fn build(b: *Build) !void {
},
}
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
var opts = b.addOptions();
opts.addOption(
[]const u8,
@@ -46,90 +49,147 @@ pub fn build(b: *Build) !void {
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
);
- const target = b.standardTargetOptions(.{});
- const optimize = b.standardOptimizeOption(.{});
+ // Build step to install html5ever dependency.
+ const html5ever_argv = blk: {
+ const argv: []const []const u8 = &.{
+ "cargo",
+ "build",
+ // Seems cargo can figure out required paths out of Cargo.toml.
+ "--manifest-path",
+ "src/html5ever/Cargo.toml",
+ // TODO: We can prefer `--artifact-dir` once it become stable.
+ "--target-dir",
+ b.getInstallPath(.prefix, "html5ever"),
+ // This must be the last argument.
+ "--release",
+ };
- // We're still using llvm because the new x86 backend seems to crash
- // with v8. This can be reproduced in zig-v8-fork.
+ break :blk switch (optimize) {
+ // Prefer dev build on debug option.
+ .Debug => argv[0 .. argv.len - 1],
+ else => argv,
+ };
+ };
+ const html5ever_exec_cargo = b.addSystemCommand(html5ever_argv);
+ const html5ever_step = b.step("html5ever", "Install html5ever dependency (requires cargo)");
+ html5ever_step.dependOn(&html5ever_exec_cargo.step);
- const lightpanda_module = b.addModule("lightpanda", .{
- .root_source_file = b.path("src/main.zig"),
- .target = target,
- .optimize = optimize,
- .link_libc = true,
- .link_libcpp = true,
- });
- try addDependencies(b, lightpanda_module, opts);
+ const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer");
+ const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers");
+
+ const lightpanda_module = blk: {
+ const mod = b.addModule("lightpanda", .{
+ .root_source_file = b.path("src/lightpanda.zig"),
+ .target = target,
+ .optimize = optimize,
+ .link_libc = true,
+ .link_libcpp = true,
+ .sanitize_c = enable_csan,
+ .sanitize_thread = enable_tsan,
+ });
+
+ try addDependencies(b, mod, opts);
+
+ break :blk mod;
+ };
+
+ const html5ever_obj = switch (optimize) {
+ .Debug => b.getInstallPath(.prefix, "html5ever/debug/liblitefetch_html5ever.a"),
+ // Release builds.
+ else => b.getInstallPath(.prefix, "html5ever/release/liblitefetch_html5ever.a"),
+ };
+
+ lightpanda_module.addObjectFile(.{ .cwd_relative = html5ever_obj });
{
// browser
- // -------
-
- // compile and install
const exe = b.addExecutable(.{
.name = "lightpanda",
.use_llvm = true,
- .root_module = lightpanda_module,
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ .sanitize_c = enable_csan,
+ .sanitize_thread = enable_tsan,
+ .imports = &.{
+ .{ .name = "lightpanda", .module = lightpanda_module },
+ },
+ }),
});
b.installArtifact(exe);
- // run
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
run_cmd.addArgs(args);
}
-
- // step
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
{
- // tests
- // ----
-
- // compile
+ // test
const tests = b.addTest(.{
.root_module = lightpanda_module,
.use_llvm = true,
.test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
});
-
const run_tests = b.addRunArtifact(tests);
+ const test_step = b.step("test", "Run unit tests");
+ test_step.dependOn(&run_tests.step);
+ }
+
+ {
+ // ZIGDOM
+ // browser
+ const exe = b.addExecutable(.{
+ .name = "legacy_test",
+ .use_llvm = true,
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main_legacy_test.zig"),
+ .target = target,
+ .optimize = optimize,
+ .sanitize_c = enable_csan,
+ .sanitize_thread = enable_tsan,
+ .imports = &.{
+ .{ .name = "lightpanda", .module = lightpanda_module },
+ },
+ }),
+ });
+ b.installArtifact(exe);
+
+ const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
- run_tests.addArgs(args);
+ run_cmd.addArgs(args);
}
-
- // step
- const tests_step = b.step("test", "Run unit tests");
- tests_step.dependOn(&run_tests.step);
+ const run_step = b.step("legacy_test", "Run the app");
+ run_step.dependOn(&run_cmd.step);
}
{
// wpt
- // -----
- const wpt_module = b.createModule(.{
- .root_source_file = b.path("src/main_wpt.zig"),
- .target = target,
- .optimize = optimize,
- });
- try addDependencies(b, wpt_module, opts);
-
- // compile and install
- const wpt = b.addExecutable(.{
+ const exe = b.addExecutable(.{
.name = "lightpanda-wpt",
.use_llvm = true,
- .root_module = wpt_module,
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/main_wpt.zig"),
+ .target = target,
+ .optimize = optimize,
+ .sanitize_c = enable_csan,
+ .sanitize_thread = enable_tsan,
+ .imports = &.{
+ .{ .name = "lightpanda", .module = lightpanda_module },
+ },
+ }),
});
+ b.installArtifact(exe);
- // run
- const wpt_cmd = b.addRunArtifact(wpt);
+ const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| {
- wpt_cmd.addArgs(args);
+ run_cmd.addArgs(args);
}
- // step
- const wpt_step = b.step("wpt", "WPT tests");
- wpt_step.dependOn(&wpt_cmd.step);
+ const run_step = b.step("wpt", "Run WPT tests");
+ run_step.dependOn(&run_cmd.step);
}
{
@@ -152,7 +212,6 @@ pub fn build(b: *Build) !void {
}
fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !void {
- try moduleNetSurf(b, mod);
mod.addImport("build_config", opts.createModule());
const target = mod.resolved_target.?;
@@ -374,14 +433,27 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo
mod.addCMacro("STDC_HEADERS", "1");
mod.addCMacro("TIME_WITH_SYS_TIME", "1");
mod.addCMacro("USE_NGHTTP2", "1");
- mod.addCMacro("USE_MBEDTLS", "1");
+ mod.addCMacro("USE_OPENSSL", "1");
+ mod.addCMacro("OPENSSL_IS_BORINGSSL", "1");
mod.addCMacro("USE_THREADS_POSIX", "1");
mod.addCMacro("USE_UNIX_SOCKETS", "1");
}
try buildZlib(b, mod);
try buildBrotli(b, mod);
- try buildMbedtls(b, mod);
+ const boringssl_dep = b.dependency("boringssl-zig", .{
+ .target = target,
+ .optimize = mod.optimize.?,
+ .force_pic = true,
+ });
+
+ const ssl = boringssl_dep.artifact("ssl");
+ ssl.bundle_ubsan_rt = false;
+ const crypto = boringssl_dep.artifact("crypto");
+ crypto.bundle_ubsan_rt = false;
+
+ mod.linkLibrary(ssl);
+ mod.linkLibrary(crypto);
try buildNghttp2(b, mod);
try buildCurl(b, mod);
@@ -397,63 +469,6 @@ fn addDependencies(b: *Build, mod: *Build.Module, opts: *Build.Step.Options) !vo
}
}
-fn moduleNetSurf(b: *Build, mod: *Build.Module) !void {
- const target = mod.resolved_target.?;
- const os = target.result.os.tag;
- const arch = target.result.cpu.arch;
-
- // iconv
- const libiconv_lib_path = try std.fmt.allocPrint(
- b.allocator,
- "vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
- .{ @tagName(os), @tagName(arch) },
- );
- const libiconv_include_path = try std.fmt.allocPrint(
- b.allocator,
- "vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
- .{ @tagName(os), @tagName(arch) },
- );
- mod.addObjectFile(b.path(libiconv_lib_path));
- mod.addIncludePath(b.path(libiconv_include_path));
-
- {
- // mimalloc
- const mimalloc = "vendor/mimalloc";
- const lib_path = try std.fmt.allocPrint(
- b.allocator,
- mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
- .{ @tagName(os), @tagName(arch) },
- );
- mod.addObjectFile(b.path(lib_path));
- mod.addIncludePath(b.path(mimalloc ++ "/include"));
- }
-
- // netsurf libs
- const ns = "vendor/netsurf";
- const ns_include_path = try std.fmt.allocPrint(
- b.allocator,
- ns ++ "/out/{s}-{s}/include",
- .{ @tagName(os), @tagName(arch) },
- );
- mod.addIncludePath(b.path(ns_include_path));
-
- const libs: [4][]const u8 = .{
- "libdom",
- "libhubbub",
- "libparserutils",
- "libwapcaplet",
- };
- inline for (libs) |lib| {
- const ns_lib_path = try std.fmt.allocPrint(
- b.allocator,
- ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a",
- .{ @tagName(os), @tagName(arch) },
- );
- mod.addObjectFile(b.path(ns_lib_path));
- mod.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
- }
-}
-
fn buildZlib(b: *Build, m: *Build.Module) !void {
const zlib = b.addLibrary(.{
.name = "zlib",
@@ -510,126 +525,6 @@ fn buildBrotli(b: *Build, m: *Build.Module) !void {
} });
}
-fn buildMbedtls(b: *Build, m: *Build.Module) !void {
- const mbedtls = b.addLibrary(.{
- .name = "mbedtls",
- .root_module = m,
- });
-
- const root = "vendor/mbedtls/";
- mbedtls.addIncludePath(b.path(root ++ "include"));
- mbedtls.addIncludePath(b.path(root ++ "library"));
-
- mbedtls.addCSourceFiles(.{ .flags = &.{}, .files = &.{
- root ++ "library/aes.c",
- root ++ "library/aesni.c",
- root ++ "library/aesce.c",
- root ++ "library/aria.c",
- root ++ "library/asn1parse.c",
- root ++ "library/asn1write.c",
- root ++ "library/base64.c",
- root ++ "library/bignum.c",
- root ++ "library/bignum_core.c",
- root ++ "library/bignum_mod.c",
- root ++ "library/bignum_mod_raw.c",
- root ++ "library/camellia.c",
- root ++ "library/ccm.c",
- root ++ "library/chacha20.c",
- root ++ "library/chachapoly.c",
- root ++ "library/cipher.c",
- root ++ "library/cipher_wrap.c",
- root ++ "library/constant_time.c",
- root ++ "library/cmac.c",
- root ++ "library/ctr_drbg.c",
- root ++ "library/des.c",
- root ++ "library/dhm.c",
- root ++ "library/ecdh.c",
- root ++ "library/ecdsa.c",
- root ++ "library/ecjpake.c",
- root ++ "library/ecp.c",
- root ++ "library/ecp_curves.c",
- root ++ "library/entropy.c",
- root ++ "library/entropy_poll.c",
- root ++ "library/error.c",
- root ++ "library/gcm.c",
- root ++ "library/hkdf.c",
- root ++ "library/hmac_drbg.c",
- root ++ "library/lmots.c",
- root ++ "library/lms.c",
- root ++ "library/md.c",
- root ++ "library/md5.c",
- root ++ "library/memory_buffer_alloc.c",
- root ++ "library/nist_kw.c",
- root ++ "library/oid.c",
- root ++ "library/padlock.c",
- root ++ "library/pem.c",
- root ++ "library/pk.c",
- root ++ "library/pk_ecc.c",
- root ++ "library/pk_wrap.c",
- root ++ "library/pkcs12.c",
- root ++ "library/pkcs5.c",
- root ++ "library/pkparse.c",
- root ++ "library/pkwrite.c",
- root ++ "library/platform.c",
- root ++ "library/platform_util.c",
- root ++ "library/poly1305.c",
- root ++ "library/psa_crypto.c",
- root ++ "library/psa_crypto_aead.c",
- root ++ "library/psa_crypto_cipher.c",
- root ++ "library/psa_crypto_client.c",
- root ++ "library/psa_crypto_ffdh.c",
- root ++ "library/psa_crypto_driver_wrappers_no_static.c",
- root ++ "library/psa_crypto_ecp.c",
- root ++ "library/psa_crypto_hash.c",
- root ++ "library/psa_crypto_mac.c",
- root ++ "library/psa_crypto_pake.c",
- root ++ "library/psa_crypto_rsa.c",
- root ++ "library/psa_crypto_se.c",
- root ++ "library/psa_crypto_slot_management.c",
- root ++ "library/psa_crypto_storage.c",
- root ++ "library/psa_its_file.c",
- root ++ "library/psa_util.c",
- root ++ "library/ripemd160.c",
- root ++ "library/rsa.c",
- root ++ "library/rsa_alt_helpers.c",
- root ++ "library/sha1.c",
- root ++ "library/sha3.c",
- root ++ "library/sha256.c",
- root ++ "library/sha512.c",
- root ++ "library/threading.c",
- root ++ "library/timing.c",
- root ++ "library/version.c",
- root ++ "library/version_features.c",
- root ++ "library/pkcs7.c",
- root ++ "library/x509.c",
- root ++ "library/x509_create.c",
- root ++ "library/x509_crl.c",
- root ++ "library/x509_crt.c",
- root ++ "library/x509_csr.c",
- root ++ "library/x509write.c",
- root ++ "library/x509write_crt.c",
- root ++ "library/x509write_csr.c",
- root ++ "library/debug.c",
- root ++ "library/mps_reader.c",
- root ++ "library/mps_trace.c",
- root ++ "library/net_sockets.c",
- root ++ "library/ssl_cache.c",
- root ++ "library/ssl_ciphersuites.c",
- root ++ "library/ssl_client.c",
- root ++ "library/ssl_cookie.c",
- root ++ "library/ssl_debug_helpers_generated.c",
- root ++ "library/ssl_msg.c",
- root ++ "library/ssl_ticket.c",
- root ++ "library/ssl_tls.c",
- root ++ "library/ssl_tls12_client.c",
- root ++ "library/ssl_tls12_server.c",
- root ++ "library/ssl_tls13_keys.c",
- root ++ "library/ssl_tls13_server.c",
- root ++ "library/ssl_tls13_client.c",
- root ++ "library/ssl_tls13_generic.c",
- } });
-}
-
fn buildNghttp2(b: *Build, m: *Build.Module) !void {
const nghttp2 = b.addLibrary(.{
.name = "nghttp2",
@@ -683,6 +578,8 @@ fn buildCurl(b: *Build, m: *Build.Module) !void {
curl.addIncludePath(b.path(root ++ "lib"));
curl.addIncludePath(b.path(root ++ "include"));
+ curl.addIncludePath(b.path("vendor/zlib"));
+
curl.addCSourceFiles(.{
.flags = &.{},
.files = &.{
@@ -841,8 +738,9 @@ fn buildCurl(b: *Build, m: *Build.Module) !void {
root ++ "lib/vauth/spnego_sspi.c",
root ++ "lib/vauth/vauth.c",
root ++ "lib/vtls/cipher_suite.c",
- root ++ "lib/vtls/mbedtls.c",
- root ++ "lib/vtls/mbedtls_threadlock.c",
+ root ++ "lib/vtls/openssl.c",
+ root ++ "lib/vtls/hostcheck.c",
+ root ++ "lib/vtls/keylog.c",
root ++ "lib/vtls/vtls.c",
root ++ "lib/vtls/vtls_scache.c",
root ++ "lib/vtls/x509asn1.c",
diff --git a/build.zig.zon b/build.zig.zon
index 9d57095f9..cb0136209 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -5,9 +5,13 @@
.fingerprint = 0xda130f3af836cea0,
.dependencies = .{
.v8 = .{
- .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/305bb3706716d32d59b2ffa674731556caa1002b.tar.gz",
- .hash = "v8-0.0.0-xddH63bVAwBSEobaUok9J0er1FqsvEujCDDVy6ItqKQ5",
+ .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/0d19781ccec829640e4f07591cbc166fa7dbe139.tar.gz",
+ .hash = "v8-0.0.0-xddH6wTgAwALFCYoZbUIqtsRyP6mr69N7aKT_cySHKN2",
},
//.v8 = .{ .path = "../zig-v8-fork" }
+ .@"boringssl-zig" = .{
+ .url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
+ .hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
+ },
},
}
diff --git a/flake.lock b/flake.lock
index 497b274e8..13693e9a8 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,26 @@
{
"nodes": {
+ "fenix": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ],
+ "rust-analyzer-src": "rust-analyzer-src"
+ },
+ "locked": {
+ "lastModified": 1763016383,
+ "narHash": "sha256-eYmo7FNvm3q08iROzwIi8i9dWuUbJJl3uLR3OLnSmdI=",
+ "owner": "nix-community",
+ "repo": "fenix",
+ "rev": "0fad5c0e5c531358e7174cd666af4608f08bc3ba",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nix-community",
+ "repo": "fenix",
+ "type": "github"
+ }
+ },
"flake-compat": {
"flake": false,
"locked": {
@@ -75,11 +96,11 @@
},
"nixpkgs": {
"locked": {
- "lastModified": 1756822655,
- "narHash": "sha256-xQAk8xLy7srAkR5NMZFsQFioL02iTHuuEIs3ohGpgdk=",
+ "lastModified": 1763043403,
+ "narHash": "sha256-DgCTbHdIpzbXSlQlOZEWj8oPt2lrRMlSk03oIstvkVQ=",
"owner": "nixos",
"repo": "nixpkgs",
- "rev": "4bdac60bfe32c41103ae500ddf894c258291dd61",
+ "rev": "75e04ecd084f93d4105ce68c07dac7656291fe2e",
"type": "github"
},
"original": {
@@ -91,12 +112,30 @@
},
"root": {
"inputs": {
+ "fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"zigPkgs": "zigPkgs",
"zlsPkg": "zlsPkg"
}
},
+ "rust-analyzer-src": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1762860488,
+ "narHash": "sha256-rMfWMCOo/pPefM2We0iMBLi2kLBAnYoB9thi4qS7uk4=",
+ "owner": "rust-lang",
+ "repo": "rust-analyzer",
+ "rev": "2efc80078029894eec0699f62ec8d5c1a56af763",
+ "type": "github"
+ },
+ "original": {
+ "owner": "rust-lang",
+ "ref": "nightly",
+ "repo": "rust-analyzer",
+ "type": "github"
+ }
+ },
"systems": {
"locked": {
"lastModified": 1681028828,
@@ -136,11 +175,11 @@
]
},
"locked": {
- "lastModified": 1756555914,
- "narHash": "sha256-7yoSPIVEuL+3Wzf6e7NHuW3zmruHizRrYhGerjRHTLI=",
+ "lastModified": 1762907712,
+ "narHash": "sha256-VNW/+VYIg6N4b9Iq+F0YZmm22n74IdFS7hsPLblWuOY=",
"owner": "mitchellh",
"repo": "zig-overlay",
- "rev": "d0df3a2fd0f11134409d6d5ea0e510e5e477f7d6",
+ "rev": "d16453ee78765e49527c56d23386cead799b6b53",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index 971f0f44c..330bbdf04 100644
--- a/flake.nix
+++ b/flake.nix
@@ -11,6 +11,11 @@
zlsPkg.inputs.zig-overlay.follows = "zigPkgs";
zlsPkg.inputs.nixpkgs.follows = "nixpkgs";
+ fenix = {
+ url = "github:nix-community/fenix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
flake-utils.url = "github:numtide/flake-utils";
};
@@ -19,6 +24,7 @@
nixpkgs,
zigPkgs,
zlsPkg,
+ fenix,
flake-utils,
...
}:
@@ -36,6 +42,8 @@
inherit system overlays;
};
+ rustToolchain = fenix.packages.${system}.stable.toolchain;
+
# We need crtbeginS.o for building.
crtFiles = pkgs.runCommand "crt-files" { } ''
mkdir -p $out/lib
@@ -49,8 +57,9 @@
targetPkgs =
pkgs: with pkgs; [
# Build Tools
- zigpkgs."0.15.1"
+ zigpkgs."0.15.2"
zls
+ rustToolchain
python3
pkg-config
cmake
@@ -66,7 +75,6 @@
glib.dev
glibc.dev
zlib
- zlib.dev
];
};
in
diff --git a/src/App.zig b/src/App.zig
new file mode 100644
index 000000000..24d015c01
--- /dev/null
+++ b/src/App.zig
@@ -0,0 +1,127 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const Allocator = std.mem.Allocator;
+
+const log = @import("log.zig");
+const Http = @import("http/Http.zig");
+const Platform = @import("browser/js/Platform.zig");
+
+const Notification = @import("Notification.zig");
+const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
+
+// Container for global state / objects that various parts of the system
+// might need.
+const App = @This();
+
+http: Http,
+config: Config,
+platform: Platform,
+telemetry: Telemetry,
+allocator: Allocator,
+app_dir_path: ?[]const u8,
+notification: *Notification,
+
+pub const RunMode = enum {
+ help,
+ fetch,
+ serve,
+ version,
+};
+
+pub const Config = struct {
+ run_mode: RunMode,
+ tls_verify_host: bool = true,
+ http_proxy: ?[:0]const u8 = null,
+ proxy_bearer_token: ?[:0]const u8 = null,
+ http_timeout_ms: ?u31 = null,
+ http_connect_timeout_ms: ?u31 = null,
+ http_max_host_open: ?u8 = null,
+ http_max_concurrent: ?u8 = null,
+ user_agent: [:0]const u8,
+};
+
+pub fn init(allocator: Allocator, config: Config) !*App {
+ const app = try allocator.create(App);
+ errdefer allocator.destroy(app);
+
+ app.config = config;
+ app.allocator = allocator;
+
+ app.notification = try Notification.init(allocator, null);
+ errdefer app.notification.deinit();
+
+ app.http = try Http.init(allocator, .{
+ .max_host_open = config.http_max_host_open orelse 4,
+ .max_concurrent = config.http_max_concurrent orelse 10,
+ .timeout_ms = config.http_timeout_ms orelse 5000,
+ .connect_timeout_ms = config.http_connect_timeout_ms orelse 0,
+ .http_proxy = config.http_proxy,
+ .tls_verify_host = config.tls_verify_host,
+ .proxy_bearer_token = config.proxy_bearer_token,
+ .user_agent = config.user_agent,
+ });
+ errdefer app.http.deinit();
+
+ app.platform = try Platform.init();
+ errdefer app.platform.deinit();
+
+ app.app_dir_path = getAndMakeAppDir(allocator);
+
+ app.telemetry = try Telemetry.init(app, config.run_mode);
+ errdefer app.telemetry.deinit();
+
+ try app.telemetry.register(app.notification);
+
+ return app;
+}
+
+pub fn deinit(self: *App) void {
+ const allocator = self.allocator;
+ if (self.app_dir_path) |app_dir_path| {
+ allocator.free(app_dir_path);
+ }
+ self.telemetry.deinit();
+ self.notification.deinit();
+ self.http.deinit();
+ self.platform.deinit();
+
+ allocator.destroy(self);
+}
+
+fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
+ if (@import("builtin").is_test) {
+ return allocator.dupe(u8, "/tmp") catch unreachable;
+ }
+ const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
+ log.warn(.app, "get data dir", .{ .err = err });
+ return null;
+ };
+
+ std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
+ error.PathAlreadyExists => return app_dir_path,
+ else => {
+ allocator.free(app_dir_path);
+ log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path });
+ return null;
+ },
+ };
+ return app_dir_path;
+}
diff --git a/src/Notification.zig b/src/Notification.zig
new file mode 100644
index 000000000..dea1d5499
--- /dev/null
+++ b/src/Notification.zig
@@ -0,0 +1,404 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const log = @import("log.zig");
+const Page = @import("browser/Page.zig");
+const Transfer = @import("http/Client.zig").Transfer;
+
+const Allocator = std.mem.Allocator;
+
+const List = std.DoublyLinkedList;
+
+// Allows code to register for and emit events.
+// Keeps two lists
+// 1 - for a given event type, a linked list of all the listeners
+// 2 - for a given listener, a list of all it's registration
+// The 2nd one is so that a listener can unregister all of it's listeners
+// (there's currently no need for a listener to unregister only 1 or more
+// specific listener).
+//
+// Scoping is important. Imagine we created a global singleton registry, and our
+// CDP code registers for the "network_bytes_sent" event, because it needs to
+// send messages to the client when this happens. Our HTTP client could then
+// emit a "network_bytes_sent" message. It would be easy, and it would work.
+// That is, it would work until the Telemetry code makes an HTTP request, and
+// because everything's just one big global, that gets picked up by the
+// registered CDP listener, and the telemetry network activity gets sent to the
+// CDP client.
+//
+// To avoid this, one way or another, we need scoping. We could still have
+// a global registry but every "register" and every "emit" has some type of
+// "scope". This would have a run-time cost and still require some coordination
+// between components to share a common scope.
+//
+// Instead, the approach that we take is to have a notification instance per
+// scope. This makes some things harder, but we only plan on having 2
+// notification instances at a given time: one in a Browser and one in the App.
+// What about something like Telemetry, which lives outside of a Browser but
+// still cares about Browser-events (like .page_navigate)? When the Browser
+// notification is created, a `notification_created` event is raised in the
+// App's notification, which Telemetry is registered for. This allows Telemetry
+// to register for events in the Browser notification. See the Telemetry's
+// register function.
+const Notification = @This();
+// Every event type (which are hard-coded), has a list of Listeners.
+// When the event happens, we dispatch to those listener.
+event_listeners: EventListeners,
+
+// list of listeners for a specified receiver
+// @intFromPtr(receiver) -> [listener1, listener2, ...]
+// Used when `unregisterAll` is called.
+listeners: std.AutoHashMapUnmanaged(usize, std.ArrayListUnmanaged(*Listener)),
+
+allocator: Allocator,
+mem_pool: std.heap.MemoryPool(Listener),
+
+const EventListeners = struct {
+ page_remove: List = .{},
+ page_created: List = .{},
+ page_navigate: List = .{},
+ page_navigated: List = .{},
+ page_network_idle: List = .{},
+ page_network_almost_idle: List = .{},
+ http_request_fail: List = .{},
+ http_request_start: List = .{},
+ http_request_intercept: List = .{},
+ http_request_done: List = .{},
+ http_request_auth_required: List = .{},
+ http_response_data: List = .{},
+ http_response_header_done: List = .{},
+ notification_created: List = .{},
+};
+
+const Events = union(enum) {
+ page_remove: PageRemove,
+ page_created: *Page,
+ page_navigate: *const PageNavigate,
+ page_navigated: *const PageNavigated,
+ page_network_idle: *const PageNetworkIdle,
+ page_network_almost_idle: *const PageNetworkAlmostIdle,
+ http_request_fail: *const RequestFail,
+ http_request_start: *const RequestStart,
+ http_request_intercept: *const RequestIntercept,
+ http_request_auth_required: *const RequestAuthRequired,
+ http_request_done: *const RequestDone,
+ http_response_data: *const ResponseData,
+ http_response_header_done: *const ResponseHeaderDone,
+ notification_created: *Notification,
+};
+const EventType = std.meta.FieldEnum(Events);
+
+pub const PageRemove = struct {};
+
+pub const PageNavigate = struct {
+ timestamp: u64,
+ url: [:0]const u8,
+ opts: Page.NavigateOpts,
+};
+
+pub const PageNavigated = struct {
+ timestamp: u64,
+ url: [:0]const u8,
+};
+
+pub const PageNetworkIdle = struct {
+ timestamp: u64,
+};
+
+pub const PageNetworkAlmostIdle = struct {
+ timestamp: u64,
+};
+
+pub const RequestStart = struct {
+ transfer: *Transfer,
+};
+
+pub const RequestIntercept = struct {
+ transfer: *Transfer,
+ wait_for_interception: *bool,
+};
+
+pub const RequestAuthRequired = struct {
+ transfer: *Transfer,
+ wait_for_interception: *bool,
+};
+
+pub const ResponseData = struct {
+ data: []const u8,
+ transfer: *Transfer,
+};
+
+pub const ResponseHeaderDone = struct {
+ transfer: *Transfer,
+};
+
+pub const RequestDone = struct {
+ transfer: *Transfer,
+};
+
+pub const RequestFail = struct {
+ transfer: *Transfer,
+ err: anyerror,
+};
+
+pub fn init(allocator: Allocator, parent: ?*Notification) !*Notification {
+
+ // This is put on the heap because we want to raise a .notification_created
+ // event, so that, something like Telemetry, can receive the
+ // .page_navigate event on all notification instances. That can only work
+ // if we dispatch .notification_created with a *Notification.
+ const notification = try allocator.create(Notification);
+ errdefer allocator.destroy(notification);
+
+ notification.* = .{
+ .listeners = .{},
+ .event_listeners = .{},
+ .allocator = allocator,
+ .mem_pool = std.heap.MemoryPool(Listener).init(allocator),
+ };
+
+ if (parent) |pn| {
+ pn.dispatch(.notification_created, notification);
+ }
+
+ return notification;
+}
+
+pub fn deinit(self: *Notification) void {
+ const allocator = self.allocator;
+
+ var it = self.listeners.valueIterator();
+ while (it.next()) |listener| {
+ listener.deinit(allocator);
+ }
+ self.listeners.deinit(allocator);
+ self.mem_pool.deinit();
+ allocator.destroy(self);
+}
+
+pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void {
+ var list = &@field(self.event_listeners, @tagName(event));
+
+ var listener = try self.mem_pool.create();
+ errdefer self.mem_pool.destroy(listener);
+
+ listener.* = .{
+ .node = .{},
+ .list = list,
+ .receiver = receiver,
+ .event = event,
+ .func = @ptrCast(func),
+ .struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
+ };
+
+ const allocator = self.allocator;
+ const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
+ if (gop.found_existing == false) {
+ gop.value_ptr.* = .{};
+ }
+ try gop.value_ptr.append(allocator, listener);
+
+ // we don't add this until we've successfully added the entry to
+ // self.listeners
+ list.append(&listener.node);
+}
+
+pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void {
+ var listeners = self.listeners.getPtr(@intFromPtr(receiver)) orelse return;
+
+ var i: usize = 0;
+ while (i < listeners.items.len) {
+ const listener = listeners.items[i];
+ if (listener.event != event) {
+ i += 1;
+ continue;
+ }
+ listener.list.remove(&listener.node);
+ self.mem_pool.destroy(listener);
+ _ = listeners.swapRemove(i);
+ }
+
+ if (listeners.items.len == 0) {
+ listeners.deinit(self.allocator);
+ const removed = self.listeners.remove(@intFromPtr(receiver));
+ std.debug.assert(removed == true);
+ }
+}
+
+pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
+ var kv = self.listeners.fetchRemove(@intFromPtr(receiver)) orelse return;
+ for (kv.value.items) |listener| {
+ listener.list.remove(&listener.node);
+ self.mem_pool.destroy(listener);
+ }
+ kv.value.deinit(self.allocator);
+}
+
+pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void {
+ const list = &@field(self.event_listeners, @tagName(event));
+
+ var node = list.first;
+ while (node) |n| {
+ const listener: *Listener = @fieldParentPtr("node", n);
+ const func: EventFunc(event) = @ptrCast(@alignCast(listener.func));
+ func(listener.receiver, data) catch |err| {
+ log.err(.app, "dispatch error", .{
+ .err = err,
+ .event = event,
+ .source = "notification",
+ .listener = listener.struct_name,
+ });
+ };
+ node = n.next;
+ }
+}
+
+// Given an event type enum, returns the type of arg the event emits
+fn ArgType(comptime event: Notification.EventType) type {
+ inline for (std.meta.fields(Notification.Events)) |f| {
+ if (std.mem.eql(u8, f.name, @tagName(event))) {
+ return f.type;
+ }
+ }
+ unreachable;
+}
+
+// Given an event type enum, returns the listening function type
+fn EventFunc(comptime event: Notification.EventType) type {
+ return *const fn (*anyopaque, ArgType(event)) anyerror!void;
+}
+
+// A listener. This is 1 receiver, with its function, and the linked list
+// node that goes in the appropriate EventListeners list.
+const Listener = struct {
+ // the receiver of the event, i.e. the self parameter to `func`
+ receiver: *anyopaque,
+
+ // the function to call
+ func: *const anyopaque,
+
+ // For logging slightly better error
+ struct_name: []const u8,
+
+ event: Notification.EventType,
+
+ // intrusive linked list node
+ node: List.Node,
+
+ // The event list this listener belongs to.
+ // We need this in order to be able to remove the node from the list
+ list: *List,
+};
+
+const testing = std.testing;
+test "Notification" {
+ var notifier = try Notification.init(testing.allocator, null);
+ defer notifier.deinit();
+
+ // noop
+ notifier.dispatch(.page_navigate, &.{
+ .timestamp = 4,
+ .url = undefined,
+ .opts = .{},
+ });
+
+ var tc = TestClient{};
+
+ try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
+ notifier.dispatch(.page_navigate, &.{
+ .timestamp = 4,
+ .url = undefined,
+ .opts = .{},
+ });
+ try testing.expectEqual(4, tc.page_navigate);
+
+ notifier.unregisterAll(&tc);
+ notifier.dispatch(.page_navigate, &.{
+ .timestamp = 10,
+ .url = undefined,
+ .opts = .{},
+ });
+ try testing.expectEqual(4, tc.page_navigate);
+
+ try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
+ try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
+ notifier.dispatch(.page_navigate, &.{
+ .timestamp = 10,
+ .url = undefined,
+ .opts = .{},
+ });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 6, .url = undefined });
+ try testing.expectEqual(14, tc.page_navigate);
+ try testing.expectEqual(6, tc.page_navigated);
+
+ notifier.unregisterAll(&tc);
+ notifier.dispatch(.page_navigate, &.{
+ .timestamp = 100,
+ .url = undefined,
+ .opts = .{},
+ });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 100, .url = undefined });
+ try testing.expectEqual(14, tc.page_navigate);
+ try testing.expectEqual(6, tc.page_navigated);
+
+ {
+ // unregister
+ try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
+ try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
+ notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined });
+ try testing.expectEqual(114, tc.page_navigate);
+ try testing.expectEqual(1006, tc.page_navigated);
+
+ notifier.unregister(.page_navigate, &tc);
+ notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined });
+ try testing.expectEqual(114, tc.page_navigate);
+ try testing.expectEqual(2006, tc.page_navigated);
+
+ notifier.unregister(.page_navigated, &tc);
+ notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined });
+ try testing.expectEqual(114, tc.page_navigate);
+ try testing.expectEqual(2006, tc.page_navigated);
+
+ // already unregistered, try anyways
+ notifier.unregister(.page_navigated, &tc);
+ notifier.dispatch(.page_navigate, &.{ .timestamp = 100, .url = undefined, .opts = .{} });
+ notifier.dispatch(.page_navigated, &.{ .timestamp = 1000, .url = undefined });
+ try testing.expectEqual(114, tc.page_navigate);
+ try testing.expectEqual(2006, tc.page_navigated);
+ }
+}
+
+const TestClient = struct {
+ page_navigate: u64 = 0,
+ page_navigated: u64 = 0,
+
+ fn pageNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void {
+ const self: *TestClient = @ptrCast(@alignCast(ptr));
+ self.page_navigate += data.timestamp;
+ }
+
+ fn pageNavigated(ptr: *anyopaque, data: *const Notification.PageNavigated) !void {
+ const self: *TestClient = @ptrCast(@alignCast(ptr));
+ self.page_navigated += data.timestamp;
+ }
+};
diff --git a/src/server.zig b/src/Server.zig
similarity index 89%
rename from src/server.zig
rename to src/Server.zig
index 974517054..34621efd0 100644
--- a/src/server.zig
+++ b/src/Server.zig
@@ -1,4 +1,4 @@
-// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier
// Pierre Tachoire
@@ -26,7 +26,7 @@ const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const log = @import("log.zig");
-const App = @import("app.zig").App;
+const App = @import("App.zig");
const CDP = @import("cdp/cdp.zig").CDP;
const MAX_HTTP_REQUEST_SIZE = 4096;
@@ -36,147 +36,146 @@ const MAX_HTTP_REQUEST_SIZE = 4096;
// +140 for the max control packet that might be interleaved in a message
const MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
-pub const Server = struct {
- app: *App,
- shutdown: bool,
- allocator: Allocator,
- client: ?posix.socket_t,
- listener: ?posix.socket_t,
- json_version_response: []const u8,
+const Server = @This();
+app: *App,
+shutdown: bool,
+allocator: Allocator,
+client: ?posix.socket_t,
+listener: ?posix.socket_t,
+json_version_response: []const u8,
- pub fn init(app: *App, address: net.Address) !Server {
- const allocator = app.allocator;
- const json_version_response = try buildJSONVersionResponse(allocator, address);
- errdefer allocator.free(json_version_response);
+pub fn init(app: *App, address: net.Address) !Server {
+ const allocator = app.allocator;
+ const json_version_response = try buildJSONVersionResponse(allocator, address);
+ errdefer allocator.free(json_version_response);
- return .{
- .app = app,
- .client = null,
- .listener = null,
- .shutdown = false,
- .allocator = allocator,
- .json_version_response = json_version_response,
- };
- }
+ return .{
+ .app = app,
+ .client = null,
+ .listener = null,
+ .shutdown = false,
+ .allocator = allocator,
+ .json_version_response = json_version_response,
+ };
+}
- pub fn deinit(self: *Server) void {
- self.shutdown = true;
- if (self.listener) |listener| {
- posix.close(listener);
- }
- // *if* server.run is running, we should really wait for it to return
- // before existing from here.
- self.allocator.free(self.json_version_response);
+pub fn deinit(self: *Server) void {
+ self.shutdown = true;
+ if (self.listener) |listener| {
+ posix.close(listener);
}
+ // *if* server.run is running, we should really wait for it to return
+ // before existing from here.
+ self.allocator.free(self.json_version_response);
+}
- pub fn run(self: *Server, address: net.Address, timeout_ms: i32) !void {
- const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC;
- const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
- self.listener = listener;
-
- try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
- if (@hasDecl(posix.TCP, "NODELAY")) {
- try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
- }
-
- try posix.bind(listener, &address.any, address.getOsSockLen());
- try posix.listen(listener, 1);
+pub fn run(self: *Server, address: net.Address, timeout_ms: u32) !void {
+ const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC;
+ const listener = try posix.socket(address.any.family, flags, posix.IPPROTO.TCP);
+ self.listener = listener;
- log.info(.app, "server running", .{ .address = address });
- while (true) {
- const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
- if (self.shutdown) {
- return;
- }
- log.err(.app, "CDP accept", .{ .err = err });
- std.Thread.sleep(std.time.ns_per_s);
- continue;
- };
+ try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)));
+ if (@hasDecl(posix.TCP, "NODELAY")) {
+ try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1)));
+ }
- self.client = socket;
- defer if (self.client) |s| {
- posix.close(s);
- self.client = null;
- };
+ try posix.bind(listener, &address.any, address.getOsSockLen());
+ try posix.listen(listener, 1);
- if (log.enabled(.app, .info)) {
- var client_address: std.net.Address = undefined;
- var socklen: posix.socklen_t = @sizeOf(net.Address);
- try std.posix.getsockname(socket, &client_address.any, &socklen);
- log.info(.app, "client connected", .{ .ip = client_address });
+ log.info(.app, "server running", .{ .address = address });
+ while (true) {
+ const socket = posix.accept(listener, null, null, posix.SOCK.NONBLOCK) catch |err| {
+ if (self.shutdown) {
+ return;
}
+ log.err(.app, "CDP accept", .{ .err = err });
+ std.Thread.sleep(std.time.ns_per_s);
+ continue;
+ };
- self.readLoop(socket, timeout_ms) catch |err| {
- log.err(.app, "CDP client loop", .{ .err = err });
- };
+ self.client = socket;
+ defer if (self.client) |s| {
+ posix.close(s);
+ self.client = null;
+ };
+
+ if (log.enabled(.app, .info)) {
+ var client_address: std.net.Address = undefined;
+ var socklen: posix.socklen_t = @sizeOf(net.Address);
+ try std.posix.getsockname(socket, &client_address.any, &socklen);
+ log.info(.app, "client connected", .{ .ip = client_address });
}
+
+ self.readLoop(socket, timeout_ms) catch |err| {
+ log.err(.app, "CDP client loop", .{ .err = err });
+ };
}
+}
- fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: i32) !void {
- // This shouldn't be necessary, but the Client is HUGE (> 512KB) because
- // it has a large read buffer. I don't know why, but v8 crashes if this
- // is on the stack (and I assume it's related to its size).
- const client = try self.allocator.create(Client);
- defer self.allocator.destroy(client);
+fn readLoop(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
+ // This shouldn't be necessary, but the Client is HUGE (> 512KB) because
+ // it has a large read buffer. I don't know why, but v8 crashes if this
+ // is on the stack (and I assume it's related to its size).
+ const client = try self.allocator.create(Client);
+ defer self.allocator.destroy(client);
- client.* = try Client.init(socket, self);
- defer client.deinit();
+ client.* = try Client.init(socket, self);
+ defer client.deinit();
- var http = &self.app.http;
- http.monitorSocket(socket);
- defer http.unmonitorSocket();
+ var http = &self.app.http;
+ http.monitorSocket(socket);
+ defer http.unmonitorSocket();
- std.debug.assert(client.mode == .http);
- while (true) {
- if (http.poll(timeout_ms) != .extra_socket) {
- log.info(.app, "CDP timeout", .{});
- return;
- }
+ std.debug.assert(client.mode == .http);
+ while (true) {
+ if (http.poll(timeout_ms) != .extra_socket) {
+ log.info(.app, "CDP timeout", .{});
+ return;
+ }
- if (try client.readSocket() == false) {
- return;
- }
+ if (try client.readSocket() == false) {
+ return;
+ }
- if (client.mode == .cdp) {
- break; // switch to our CDP loop
- }
+ if (client.mode == .cdp) {
+ break; // switch to our CDP loop
}
+ }
- var cdp = &client.mode.cdp;
- var last_message = timestamp();
- var ms_remaining = timeout_ms;
- while (true) {
- switch (cdp.pageWait(ms_remaining)) {
- .extra_socket => {
- if (try client.readSocket() == false) {
- return;
- }
- last_message = timestamp();
- ms_remaining = timeout_ms;
- },
- .no_page => {
- if (http.poll(ms_remaining) != .extra_socket) {
- log.info(.app, "CDP timeout", .{});
- return;
- }
- if (try client.readSocket() == false) {
- return;
- }
- last_message = timestamp();
- ms_remaining = timeout_ms;
- },
- .done => {
- const elapsed = timestamp() - last_message;
- if (elapsed > ms_remaining) {
- log.info(.app, "CDP timeout", .{});
- return;
- }
- ms_remaining -= @as(i32, @intCast(elapsed));
- },
- }
+ var cdp = &client.mode.cdp;
+ var last_message = timestamp(.monotonic);
+ var ms_remaining = timeout_ms;
+ while (true) {
+ switch (cdp.pageWait(ms_remaining)) {
+ .extra_socket => {
+ if (try client.readSocket() == false) {
+ return;
+ }
+ last_message = timestamp(.monotonic);
+ ms_remaining = timeout_ms;
+ },
+ .no_page => {
+ if (http.poll(ms_remaining) != .extra_socket) {
+ log.info(.app, "CDP timeout", .{});
+ return;
+ }
+ if (try client.readSocket() == false) {
+ return;
+ }
+ last_message = timestamp(.monotonic);
+ ms_remaining = timeout_ms;
+ },
+ .done => {
+ const elapsed = timestamp(.monotonic) - last_message;
+ if (elapsed > ms_remaining) {
+ log.info(.app, "CDP timeout", .{});
+ return;
+ }
+ ms_remaining -= @intCast(elapsed);
+ },
}
}
-};
+}
pub const Client = struct {
// The client is initially serving HTTP requests but, under normal circumstances
@@ -487,7 +486,7 @@ pub const Client = struct {
}
// called by CDP
- // Websocket frames have a variable lenght header. For server-client,
+ // Websocket frames have a variable length header. For server-client,
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
// writev, so we need to get creative. We'll JSON serialize to a
// buffer, where the first 10 bytes are reserved. We can then backfill
@@ -929,9 +928,7 @@ fn buildJSONVersionResponse(
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
}
-fn timestamp() u32 {
- return @import("datetime.zig").timestamp();
-}
+pub const timestamp = @import("datetime.zig").timestamp;
// In-place string lowercase
fn toLower(str: []u8) []u8 {
diff --git a/src/TestHTTPServer.zig b/src/TestHTTPServer.zig
index 9867600d0..fdf4e1247 100644
--- a/src/TestHTTPServer.zig
+++ b/src/TestHTTPServer.zig
@@ -1,3 +1,21 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
const std = @import("std");
const TestHTTPServer = @This();
@@ -61,6 +79,7 @@ fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !voi
return err;
},
};
+
self.handler(&req) catch |err| {
std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err });
try req.respond("server error", .{ .status = .internal_server_error });
diff --git a/src/app.zig b/src/app.zig
deleted file mode 100644
index 719dd9b72..000000000
--- a/src/app.zig
+++ /dev/null
@@ -1,115 +0,0 @@
-const std = @import("std");
-
-const Allocator = std.mem.Allocator;
-
-const log = @import("log.zig");
-const Http = @import("http/Http.zig");
-const Platform = @import("browser/js/Platform.zig");
-
-const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
-const Notification = @import("notification.zig").Notification;
-
-// Container for global state / objects that various parts of the system
-// might need.
-pub const App = struct {
- http: Http,
- config: Config,
- platform: Platform,
- allocator: Allocator,
- telemetry: Telemetry,
- app_dir_path: ?[]const u8,
- notification: *Notification,
-
- pub const RunMode = enum {
- help,
- fetch,
- serve,
- version,
- };
-
- pub const Config = struct {
- run_mode: RunMode,
- tls_verify_host: bool = true,
- http_proxy: ?[:0]const u8 = null,
- proxy_bearer_token: ?[:0]const u8 = null,
- http_timeout_ms: ?u31 = null,
- http_connect_timeout_ms: ?u31 = null,
- http_max_host_open: ?u8 = null,
- http_max_concurrent: ?u8 = null,
- user_agent: [:0]const u8,
- };
-
- pub fn init(allocator: Allocator, config: Config) !*App {
- const app = try allocator.create(App);
- errdefer allocator.destroy(app);
-
- const notification = try Notification.init(allocator, null);
- errdefer notification.deinit();
-
- var http = try Http.init(allocator, .{
- .max_host_open = config.http_max_host_open orelse 4,
- .max_concurrent = config.http_max_concurrent orelse 10,
- .timeout_ms = config.http_timeout_ms orelse 5000,
- .connect_timeout_ms = config.http_connect_timeout_ms orelse 0,
- .http_proxy = config.http_proxy,
- .tls_verify_host = config.tls_verify_host,
- .proxy_bearer_token = config.proxy_bearer_token,
- .user_agent = config.user_agent,
- });
- errdefer http.deinit();
-
- const platform = try Platform.init();
- errdefer platform.deinit();
-
- const app_dir_path = getAndMakeAppDir(allocator);
-
- app.* = .{
- .http = http,
- .allocator = allocator,
- .telemetry = undefined,
- .platform = platform,
- .app_dir_path = app_dir_path,
- .notification = notification,
- .config = config,
- };
-
- app.telemetry = try Telemetry.init(app, config.run_mode);
- errdefer app.telemetry.deinit();
-
- try app.telemetry.register(app.notification);
-
- return app;
- }
-
- pub fn deinit(self: *App) void {
- const allocator = self.allocator;
- if (self.app_dir_path) |app_dir_path| {
- allocator.free(app_dir_path);
- }
- self.telemetry.deinit();
- self.notification.deinit();
- self.http.deinit();
- self.platform.deinit();
- allocator.destroy(self);
- }
-};
-
-fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
- if (@import("builtin").is_test) {
- return allocator.dupe(u8, "/tmp") catch unreachable;
- }
- const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
- log.warn(.app, "get data dir", .{ .err = err });
- return null;
- };
-
- std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
- error.PathAlreadyExists => return app_dir_path,
- else => {
- allocator.free(app_dir_path);
- log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path });
- return null;
- },
- };
- return app_dir_path;
-}
diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig
new file mode 100644
index 000000000..1d3bcbfeb
--- /dev/null
+++ b/src/browser/Browser.zig
@@ -0,0 +1,110 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const Allocator = std.mem.Allocator;
+const ArenaAllocator = std.heap.ArenaAllocator;
+
+const js = @import("js/js.zig");
+const log = @import("../log.zig");
+const App = @import("../App.zig");
+const HttpClient = @import("../http/Client.zig");
+const Notification = @import("../Notification.zig");
+
+const Session = @import("Session.zig");
+
+// Browser is an instance of the browser.
+// You can create multiple browser instances.
+// A browser contains only one session.
+const Browser = @This();
+
+env: *js.Env,
+app: *App,
+session: ?Session,
+allocator: Allocator,
+http_client: *HttpClient,
+call_arena: ArenaAllocator,
+page_arena: ArenaAllocator,
+session_arena: ArenaAllocator,
+transfer_arena: ArenaAllocator,
+notification: *Notification,
+
+pub fn init(app: *App) !Browser {
+ const allocator = app.allocator;
+
+ const env = try js.Env.init(allocator, &app.platform, .{});
+ errdefer env.deinit();
+
+ const notification = try Notification.init(allocator, app.notification);
+ app.http.client.notification = notification;
+ app.http.client.next_request_id = 0; // Should we track ids in CDP only?
+ errdefer notification.deinit();
+
+ return .{
+ .app = app,
+ .env = env,
+ .session = null,
+ .allocator = allocator,
+ .notification = notification,
+ .http_client = app.http.client,
+ .call_arena = ArenaAllocator.init(allocator),
+ .page_arena = ArenaAllocator.init(allocator),
+ .session_arena = ArenaAllocator.init(allocator),
+ .transfer_arena = ArenaAllocator.init(allocator),
+ };
+}
+
+pub fn deinit(self: *Browser) void {
+ self.closeSession();
+ self.env.deinit();
+ self.call_arena.deinit();
+ self.page_arena.deinit();
+ self.session_arena.deinit();
+ self.transfer_arena.deinit();
+ self.http_client.notification = null;
+ self.notification.deinit();
+}
+
+pub fn newSession(self: *Browser) !*Session {
+ self.closeSession();
+ self.session = @as(Session, undefined);
+ const session = &self.session.?;
+ try Session.init(session, self);
+ return session;
+}
+
+pub fn closeSession(self: *Browser) void {
+ if (self.session) |*session| {
+ session.deinit();
+ self.session = null;
+ _ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
+ self.env.lowMemoryNotification();
+ }
+}
+
+pub fn runMicrotasks(self: *const Browser) void {
+ self.env.runMicrotasks();
+}
+
+pub fn runMessageLoop(self: *const Browser) void {
+ while (self.env.pumpMessageLoop()) {
+ log.debug(.browser, "pumpMessageLoop", .{});
+ }
+ self.env.runIdleTasks();
+}
diff --git a/src/browser/DataURI.zig b/src/browser/DataURI.zig
deleted file mode 100644
index 00d3792f1..000000000
--- a/src/browser/DataURI.zig
+++ /dev/null
@@ -1,52 +0,0 @@
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-
-// Parses data:[][;base64],
-pub fn parse(allocator: Allocator, src: []const u8) !?[]const u8 {
- if (!std.mem.startsWith(u8, src, "data:")) {
- return null;
- }
-
- const uri = src[5..];
- const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
-
- var data = uri[data_starts + 1 ..];
-
- // Extract the encoding.
- const metadata = uri[0..data_starts];
- if (std.mem.endsWith(u8, metadata, ";base64")) {
- const decoder = std.base64.standard.Decoder;
- const decoded_size = try decoder.calcSizeForSlice(data);
-
- const buffer = try allocator.alloc(u8, decoded_size);
- errdefer allocator.free(buffer);
-
- try decoder.decode(buffer, data);
- data = buffer;
- }
-
- return data;
-}
-
-const testing = @import("../testing.zig");
-test "DataURI: parse valid" {
- try test_valid("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
- try test_valid("data:text/javascript; charset=utf-8;,foo", "foo");
- try test_valid("data:,foo", "foo");
-}
-
-test "DataURI: parse invalid" {
- try test_cannot_parse("atad:,foo");
- try test_cannot_parse("data:foo");
- try test_cannot_parse("data:");
-}
-
-fn test_valid(uri: []const u8, expected: []const u8) !void {
- defer testing.reset();
- const data_uri = try parse(testing.arena_allocator, uri) orelse return error.TestFailed;
- try testing.expectEqual(expected, data_uri);
-}
-
-fn test_cannot_parse(uri: []const u8) !void {
- try testing.expectEqual(null, parse(undefined, uri));
-}
diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
new file mode 100644
index 000000000..d458a3b2e
--- /dev/null
+++ b/src/browser/EventManager.zig
@@ -0,0 +1,446 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const builtin = @import("builtin");
+
+const log = @import("../log.zig");
+const String = @import("../string.zig").String;
+
+const js = @import("js/js.zig");
+const Page = @import("Page.zig");
+
+const Node = @import("webapi/Node.zig");
+const Event = @import("webapi/Event.zig");
+const EventTarget = @import("webapi/EventTarget.zig");
+
+const Allocator = std.mem.Allocator;
+
+const IS_DEBUG = builtin.mode == .Debug;
+
+pub const EventManager = @This();
+
+page: *Page,
+arena: Allocator,
+listener_pool: std.heap.MemoryPool(Listener),
+lookup: std.AutoHashMapUnmanaged(usize, std.DoublyLinkedList),
+dispatch_depth: u32 = 0,
+
+pub fn init(page: *Page) EventManager {
+ return .{
+ .page = page,
+ .lookup = .{},
+ .arena = page.arena,
+ .listener_pool = std.heap.MemoryPool(Listener).init(page.arena),
+ .dispatch_depth = 0,
+ };
+}
+
+pub const RegisterOptions = struct {
+ once: bool = false,
+ capture: bool = false,
+ passive: bool = false,
+ signal: ?*@import("webapi/AbortSignal.zig") = null,
+};
+pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, opts: RegisterOptions) !void {
+ if (comptime IS_DEBUG) {
+ log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once });
+ }
+
+ // If a signal is provided and already aborted, don't register the listener
+ if (opts.signal) |signal| {
+ if (signal.getAborted()) {
+ return;
+ }
+ }
+
+ const gop = try self.lookup.getOrPut(self.arena, @intFromPtr(target));
+ if (gop.found_existing) {
+ // check for duplicate functions already registered
+ var node = gop.value_ptr.first;
+ while (node) |n| {
+ const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
+ if (listener.function.eql(function) and listener.capture == opts.capture) {
+ return;
+ }
+ node = n.next;
+ }
+ } else {
+ gop.value_ptr.* = .{};
+ }
+
+ const listener = try self.listener_pool.create();
+ listener.* = .{
+ .node = .{},
+ .once = opts.once,
+ .capture = opts.capture,
+ .passive = opts.passive,
+ .function = .{ .value = function },
+ .signal = opts.signal,
+ .typ = try String.init(self.arena, typ, .{}),
+ };
+ // append the listener to the list of listeners for this target
+ gop.value_ptr.append(&listener.node);
+}
+
+pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, function: js.Function, use_capture: bool) void {
+ const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
+ if (findListener(list, typ, function, use_capture)) |listener| {
+ self.removeListener(list, listener);
+ }
+}
+
+pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) !void {
+ if (comptime IS_DEBUG) {
+ log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
+ }
+
+ event._target = target;
+ var was_handled = false;
+
+ defer if (was_handled) {
+ self.page.js.runMicrotasks();
+ };
+
+ switch (target._type) {
+ .node => |node| try self.dispatchNode(node, event, &was_handled),
+ .xhr, .window, .abort_signal, .media_query_list, .message_port, .text_track_cue, .navigation, .screen, .screen_orientation => {
+ const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
+ try self.dispatchAll(list, target, event, &was_handled);
+ },
+ }
+}
+
+// There are a lot of events that can be attached via addEventListener or as
+// a property, like the XHR events, or window.onload. You might think that the
+// property is just a shortcut for calling addEventListener, but they are distinct.
+// An event set via property cannot be removed by removeEventListener. If you
+// set both the property and add a listener, they both execute.
+const DispatchWithFunctionOptions = struct {
+ context: []const u8,
+ inject_target: bool = true,
+};
+pub fn dispatchWithFunction(self: *EventManager, target: *EventTarget, event: *Event, function_: ?js.Function, comptime opts: DispatchWithFunctionOptions) !void {
+ if (comptime IS_DEBUG) {
+ log.debug(.event, "dispatchWithFunction", .{ .type = event._type_string.str(), .context = opts.context, .has_function = function_ != null });
+ }
+
+ if (comptime opts.inject_target) {
+ event._target = target;
+ }
+
+ var was_dispatched = false;
+ defer if (was_dispatched) {
+ self.page.js.runMicrotasks();
+ };
+
+ if (function_) |func| {
+ event._current_target = target;
+ if (func.call(void, .{event})) {
+ was_dispatched = true;
+ } else |err| {
+ // a non-JS error
+ log.warn(.event, opts.context, .{ .err = err });
+ }
+ }
+
+ const list = self.lookup.getPtr(@intFromPtr(target)) orelse return;
+ try self.dispatchAll(list, target, event, &was_dispatched);
+}
+
+fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled: *bool) !void {
+ const ShadowRoot = @import("webapi/ShadowRoot.zig");
+
+ var path_len: usize = 0;
+ var path_buffer: [128]*EventTarget = undefined;
+
+ var node: ?*Node = target;
+ while (node) |n| {
+ if (path_len >= path_buffer.len) break;
+ path_buffer[path_len] = n.asEventTarget();
+ path_len += 1;
+
+ // Check if this node is a shadow root
+ if (n.is(ShadowRoot)) |shadow| {
+ event._needs_retargeting = true;
+
+ // If event is not composed, stop at shadow boundary
+ if (!event._composed) {
+ break;
+ }
+
+ // Otherwise, jump to the shadow host and continue
+ node = shadow._host.asNode();
+ continue;
+ }
+
+ node = n._parent;
+ }
+
+ // Even though the window isn't part of the DOM, events always propagate
+ // through it in the capture phase (unless we stopped at a shadow boundary)
+ if (path_len < path_buffer.len) {
+ path_buffer[path_len] = self.page.window.asEventTarget();
+ path_len += 1;
+ }
+
+ const path = path_buffer[0..path_len];
+
+ // Phase 1: Capturing phase (root → target, excluding target)
+ // This happens for all events, regardless of bubbling
+ event._event_phase = .capturing_phase;
+ var i: usize = path_len;
+ while (i > 1) {
+ i -= 1;
+ const current_target = path[i];
+ if (self.lookup.getPtr(@intFromPtr(current_target))) |list| {
+ try self.dispatchPhase(list, current_target, event, was_handled, true);
+ if (event._stop_propagation) {
+ event._event_phase = .none;
+ return;
+ }
+ }
+ }
+
+ // Phase 2: At target
+ event._event_phase = .at_target;
+ const target_et = target.asEventTarget();
+ if (self.lookup.getPtr(@intFromPtr(target_et))) |list| {
+ try self.dispatchPhase(list, target_et, event, was_handled, null);
+ if (event._stop_propagation) {
+ event._event_phase = .none;
+ return;
+ }
+ }
+
+ // Phase 3: Bubbling phase (target → root, excluding target)
+ // This only happens if the event bubbles
+ if (event._bubbles) {
+ event._event_phase = .bubbling_phase;
+ for (path[1..]) |current_target| {
+ if (self.lookup.getPtr(@intFromPtr(current_target))) |list| {
+ try self.dispatchPhase(list, current_target, event, was_handled, false);
+ if (event._stop_propagation) {
+ break;
+ }
+ }
+ }
+ }
+
+ event._event_phase = .none;
+}
+
+fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, comptime capture_only: ?bool) !void {
+ const page = self.page;
+ const typ = event._type_string;
+
+ // Track that we're dispatching to prevent immediate 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);
+ }
+
+ var node = list.first;
+ 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 (!listener.typ.eql(typ)) {
+ continue;
+ }
+
+ // Can be null when dispatching to the target itself
+ if (comptime capture_only) |capture| {
+ if (listener.capture != capture) {
+ continue;
+ }
+ }
+
+ // If the listener has an aborted signal, remove it and skip
+ if (listener.signal) |signal| {
+ if (signal.getAborted()) {
+ self.removeListener(list, listener);
+ continue;
+ }
+ }
+
+ was_handled.* = true;
+ event._current_target = current_target;
+
+ // Compute adjusted target for shadow DOM retargeting (only if needed)
+ const original_target = event._target;
+ if (event._needs_retargeting) {
+ event._target = getAdjustedTarget(original_target, current_target);
+ }
+
+ switch (listener.function) {
+ .value => |value| try value.call(void, .{event}),
+ .string => |string| {
+ const str = try page.call_arena.dupeZ(u8, string.str());
+ try self.page.js.eval(str, null);
+ },
+ }
+
+ // Restore original target (only if we changed it)
+ if (event._needs_retargeting) {
+ event._target = original_target;
+ }
+
+ if (listener.once) {
+ self.removeListener(list, listener);
+ }
+
+ if (event._stop_immediate_propagation) {
+ return;
+ }
+ }
+}
+
+// Non-Node dispatching (XHR, Window without propagation)
+fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool) !void {
+ return self.dispatchPhase(list, current_target, event, was_handled, null);
+}
+
+fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
+ 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;
+ } else {
+ // Safe to 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, function: js.Function, capture: bool) ?*Listener {
+ var node = list.first;
+ while (node) |n| {
+ node = n.next;
+ const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
+ if (!listener.function.eql(function)) {
+ continue;
+ }
+ if (listener.capture != capture) {
+ continue;
+ }
+ if (!listener.typ.eqlSlice(typ)) {
+ continue;
+ }
+ return listener;
+ }
+ return null;
+}
+
+const Listener = struct {
+ typ: String,
+ once: bool,
+ capture: bool,
+ passive: bool,
+ function: Function,
+ signal: ?*@import("webapi/AbortSignal.zig") = null,
+ node: std.DoublyLinkedList.Node,
+ marked_for_removal: bool = false,
+};
+
+const Function = union(enum) {
+ value: js.Function,
+ string: String,
+
+ fn eql(self: Function, func: js.Function) bool {
+ return switch (self) {
+ .string => false,
+ .value => |v| return v.id == func.id,
+ };
+ }
+};
+
+// Computes the adjusted target for shadow DOM event retargeting
+// Returns the lowest shadow-including ancestor of original_target that is
+// also an ancestor-or-self of current_target
+fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
+ const ShadowRoot = @import("webapi/ShadowRoot.zig");
+
+ const orig_node = switch ((original_target orelse return null)._type) {
+ .node => |n| n,
+ else => return original_target,
+ };
+ const curr_node = switch (current_target._type) {
+ .node => |n| n,
+ else => return original_target,
+ };
+
+ // Walk up from original target, checking if we can reach current target
+ var node: ?*Node = orig_node;
+ while (node) |n| {
+ // Check if current_target is an ancestor of n (or n itself)
+ if (isAncestorOrSelf(curr_node, n)) {
+ return n.asEventTarget();
+ }
+
+ // Cross shadow boundary if needed
+ if (n.is(ShadowRoot)) |shadow| {
+ node = shadow._host.asNode();
+ continue;
+ }
+
+ node = n._parent;
+ }
+
+ return original_target;
+}
+
+// Check if ancestor is an ancestor of (or the same as) node
+// WITHOUT crossing shadow boundaries (just regular DOM tree)
+fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
+ if (ancestor == node) {
+ return true;
+ }
+
+ var current: ?*Node = node._parent;
+ while (current) |n| {
+ if (n == ancestor) {
+ return true;
+ }
+ current = n._parent;
+ }
+
+ return false;
+}
diff --git a/src/browser/Factory.zig b/src/browser/Factory.zig
new file mode 100644
index 000000000..6a0de8037
--- /dev/null
+++ b/src/browser/Factory.zig
@@ -0,0 +1,445 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const assert = std.debug.assert;
+const builtin = @import("builtin");
+const reflect = @import("reflect.zig");
+const IS_DEBUG = builtin.mode == .Debug;
+
+const log = @import("../log.zig");
+const String = @import("../string.zig").String;
+
+const SlabAllocator = @import("../slab.zig").SlabAllocator;
+
+const Page = @import("Page.zig");
+const Node = @import("webapi/Node.zig");
+const Event = @import("webapi/Event.zig");
+const Element = @import("webapi/Element.zig");
+const Document = @import("webapi/Document.zig");
+const EventTarget = @import("webapi/EventTarget.zig");
+const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
+const Blob = @import("webapi/Blob.zig");
+
+const Factory = @This();
+_page: *Page,
+_slab: SlabAllocator,
+
+fn PrototypeChain(comptime types: []const type) type {
+ return struct {
+ const Self = @This();
+ memory: []u8,
+
+ fn totalSize() usize {
+ var size: usize = 0;
+ for (types) |T| {
+ size = std.mem.alignForward(usize, size, @alignOf(T));
+ size += @sizeOf(T);
+ }
+ return size;
+ }
+
+ fn maxAlign() std.mem.Alignment {
+ var alignment: std.mem.Alignment = .@"1";
+
+ for (types) |T| {
+ alignment = std.mem.Alignment.max(alignment, std.mem.Alignment.of(T));
+ }
+
+ return alignment;
+ }
+
+ fn getType(comptime index: usize) type {
+ return types[index];
+ }
+
+ fn allocate(allocator: std.mem.Allocator) !Self {
+ const size = comptime Self.totalSize();
+ const alignment = comptime Self.maxAlign();
+
+ const memory = try allocator.alignedAlloc(u8, alignment, size);
+ return .{ .memory = memory };
+ }
+
+ fn get(self: *const Self, comptime index: usize) *getType(index) {
+ var offset: usize = 0;
+ inline for (types, 0..) |T, i| {
+ offset = std.mem.alignForward(usize, offset, @alignOf(T));
+
+ if (i == index) {
+ return @as(*T, @ptrCast(@alignCast(self.memory.ptr + offset)));
+ }
+ offset += @sizeOf(T);
+ }
+ unreachable;
+ }
+
+ fn set(self: *const Self, comptime index: usize, value: getType(index)) void {
+ const ptr = self.get(index);
+ ptr.* = value;
+ }
+
+ fn setRoot(self: *const Self, comptime T: type) void {
+ const ptr = self.get(0);
+ ptr.* = .{ ._type = unionInit(T, self.get(1)) };
+ }
+
+ fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void {
+ assert(index >= 1);
+ assert(index < types.len);
+
+ const ptr = self.get(index);
+ ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, self.get(index + 1)) };
+ }
+
+ fn setMiddleWithValue(self: *const Self, comptime index: usize, comptime T: type, value: anytype) void {
+ assert(index >= 1);
+
+ const ptr = self.get(index);
+ ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, value) };
+ }
+
+ fn setLeaf(self: *const Self, comptime index: usize, value: anytype) void {
+ assert(index >= 1);
+
+ const ptr = self.get(index);
+ ptr.* = value;
+ ptr._proto = self.get(index - 1);
+ }
+ };
+}
+
+fn AutoPrototypeChain(comptime types: []const type) type {
+ return struct {
+ fn create(allocator: std.mem.Allocator, leaf_value: anytype) !*@TypeOf(leaf_value) {
+ const chain = try PrototypeChain(types).allocate(allocator);
+
+ const RootType = types[0];
+ chain.setRoot(RootType.Type);
+
+ inline for (1..types.len - 1) |i| {
+ const MiddleType = types[i];
+ chain.setMiddle(i, MiddleType.Type);
+ }
+
+ chain.setLeaf(types.len - 1, leaf_value);
+ return chain.get(types.len - 1);
+ }
+ };
+}
+
+pub fn init(page: *Page) Factory {
+ return .{
+ ._page = page,
+ ._slab = SlabAllocator.init(page.arena, 128),
+ };
+}
+
+// this is a root object
+pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ const chain = try PrototypeChain(
+ &.{ EventTarget, @TypeOf(child) },
+ ).allocate(allocator);
+
+ const event_ptr = chain.get(0);
+ event_ptr.* = .{
+ ._type = unionInit(EventTarget.Type, chain.get(1)),
+ };
+ chain.setLeaf(1, child);
+
+ return chain.get(1);
+}
+
+// this is a root object
+pub fn event(self: *Factory, typ: []const u8, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+
+ // Special case: Event has a _type_string field, so we need manual setup
+ const chain = try PrototypeChain(
+ &.{ Event, @TypeOf(child) },
+ ).allocate(allocator);
+
+ const event_ptr = chain.get(0);
+ event_ptr.* = .{
+ ._type = unionInit(Event.Type, chain.get(1)),
+ ._type_string = try String.init(self._page.arena, typ, .{}),
+ };
+ chain.setLeaf(1, child);
+
+ return chain.get(1);
+}
+
+pub fn blob(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+
+ // Special case: Blob has slice and mime fields, so we need manual setup
+ const chain = try PrototypeChain(
+ &.{ Blob, @TypeOf(child) },
+ ).allocate(allocator);
+
+ const blob_ptr = chain.get(0);
+ blob_ptr.* = .{
+ ._type = unionInit(Blob.Type, chain.get(1)),
+ ._slice = "",
+ ._mime = "",
+ };
+ chain.setLeaf(1, child);
+
+ return chain.get(1);
+}
+
+pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, Document, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn documentFragment(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, Node.DocumentFragment, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, Element, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, Element, Element.Html, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ return try AutoPrototypeChain(
+ &.{ EventTarget, Node, Element, Element.Html, Element.Html.Media, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ const ChildT = @TypeOf(child);
+
+ if (ChildT == Element.Svg) {
+ return self.element(child);
+ }
+
+ const chain = try PrototypeChain(
+ &.{ EventTarget, Node, Element, Element.Svg, ChildT },
+ ).allocate(allocator);
+
+ chain.setRoot(EventTarget.Type);
+ chain.setMiddle(1, Node.Type);
+ chain.setMiddle(2, Element.Type);
+
+ // will never allocate, can't fail
+ const tag_name_str = String.init(self._page.arena, tag_name, .{}) catch unreachable;
+
+ // Manually set Element.Svg with the tag_name
+ chain.set(3, .{
+ ._proto = chain.get(2),
+ ._tag_name = tag_name_str,
+ ._type = unionInit(Element.Svg.Type, chain.get(4)),
+ });
+
+ chain.setLeaf(4, child);
+ return chain.get(4);
+}
+
+pub fn xhrEventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+
+ return try AutoPrototypeChain(
+ &.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
+ const allocator = self._slab.allocator();
+ const TextTrackCue = @import("webapi/media/TextTrackCue.zig");
+
+ return try AutoPrototypeChain(
+ &.{ EventTarget, TextTrackCue, @TypeOf(child) },
+ ).create(allocator, child);
+}
+
+fn hasChainRoot(comptime T: type) bool {
+ // Check if this is a root
+ if (@hasDecl(T, "_prototype_root")) {
+ return true;
+ }
+
+ // If no _proto field, we're at the top but not a recognized root
+ if (!@hasField(T, "_proto")) return false;
+
+ // Get the _proto field's type and recurse
+ const fields = @typeInfo(T).@"struct".fields;
+ inline for (fields) |field| {
+ if (std.mem.eql(u8, field.name, "_proto")) {
+ const ProtoType = reflect.Struct(field.type);
+ return hasChainRoot(ProtoType);
+ }
+ }
+
+ return false;
+}
+
+fn isChainType(comptime T: type) bool {
+ if (@hasField(T, "_proto")) return false;
+ return comptime hasChainRoot(T);
+}
+
+pub fn destroy(self: *Factory, value: anytype) void {
+ const S = reflect.Struct(@TypeOf(value));
+
+ if (comptime IS_DEBUG) {
+ // We should always destroy from the leaf down.
+ if (@hasDecl(S, "_prototype_root")) {
+ // A Event{._type == .generic} (or any other similar types)
+ // _should_ be destoyed directly. The _type = .generic is a pseudo
+ // child
+ if (S != Event or value._type != .generic) {
+ log.fatal(.bug, "factory.destroy.event", .{ .type = @typeName(S) });
+ unreachable;
+ }
+ }
+ }
+
+ if (comptime isChainType(S)) {
+ self.destroyChain(value, true, 0, std.mem.Alignment.@"1");
+ } else {
+ self.destroyStandalone(value);
+ }
+}
+
+pub fn destroyStandalone(self: *Factory, value: anytype) void {
+ const S = reflect.Struct(@TypeOf(value));
+ assert(!@hasDecl(S, "_prototype_root"));
+
+ const allocator = self._slab.allocator();
+
+ if (@hasDecl(S, "deinit")) {
+ // And it has a deinit, we'll call it
+ switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
+ 1 => value.deinit(),
+ 2 => value.deinit(self._page),
+ else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
+ }
+ }
+
+ allocator.destroy(value);
+}
+
+fn destroyChain(
+ self: *Factory,
+ value: anytype,
+ comptime first: bool,
+ old_size: usize,
+ old_align: std.mem.Alignment,
+) void {
+ const S = reflect.Struct(@TypeOf(value));
+ const allocator = self._slab.allocator();
+
+ // aligns the old size to the alignment of this element
+ const current_size = std.mem.alignForward(usize, old_size, @alignOf(S));
+ const alignment = std.mem.Alignment.fromByteUnits(@alignOf(S));
+
+ const new_align = std.mem.Alignment.max(old_align, alignment);
+ const new_size = current_size + @sizeOf(S);
+
+ // This is initially called from a deinit. We don't want to call that
+ // same deinit. So when this is the first time destroyChain is called
+ // we don't call deinit (because we're in that deinit)
+ if (!comptime first) {
+ // But if it isn't the first time
+ if (@hasDecl(S, "deinit")) {
+ // And it has a deinit, we'll call it
+ switch (@typeInfo(@TypeOf(S.deinit)).@"fn".params.len) {
+ 1 => value.deinit(),
+ 2 => value.deinit(self._page),
+ else => @compileLog(@typeName(S) ++ " has an invalid deinit function"),
+ }
+ }
+ }
+
+ if (@hasField(S, "_proto")) {
+ self.destroyChain(value._proto, false, new_size, new_align);
+ } else if (@hasDecl(S, "JsApi")) {
+ // Doesn't have a _proto, but has a JsApi.
+ if (self._page.js.removeTaggedMapping(@intFromPtr(value))) |tagged| {
+ allocator.destroy(tagged);
+ }
+ } else {
+ // no proto so this is the head of the chain.
+ // we use this as the ptr to the start of the chain.
+ // and we have summed up the length.
+ assert(@hasDecl(S, "_prototype_root"));
+
+ const memory_ptr: [*]const u8 = @ptrCast(value);
+ const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits());
+ allocator.free(memory_ptr[0..len]);
+ }
+}
+
+pub fn createT(self: *Factory, comptime T: type) !*T {
+ const allocator = self._slab.allocator();
+ return try allocator.create(T);
+}
+
+pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) {
+ const ptr = try self.createT(@TypeOf(value));
+ ptr.* = value;
+ return ptr;
+}
+
+fn unionInit(comptime T: type, value: anytype) T {
+ const V = @TypeOf(value);
+ const field_name = comptime unionFieldName(T, V);
+ return @unionInit(T, field_name, value);
+}
+
+// There can be friction between comptime and runtime. Comptime has to
+// account for all possible types, even if some runtime flow makes certain
+// cases impossible. At runtime, we always call `unionFieldName` with the
+// correct struct or pointer type. But at comptime time, `unionFieldName`
+// is called with both variants (S and *S). So we use reflect.Struct().
+// This only works because we never have a union with a field S and another
+// field *S.
+fn unionFieldName(comptime T: type, comptime V: type) []const u8 {
+ inline for (@typeInfo(T).@"union".fields) |field| {
+ if (reflect.Struct(field.type) == reflect.Struct(V)) {
+ return field.name;
+ }
+ }
+ @compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type");
+}
diff --git a/src/browser/Mime.zig b/src/browser/Mime.zig
new file mode 100644
index 000000000..27fe35a85
--- /dev/null
+++ b/src/browser/Mime.zig
@@ -0,0 +1,518 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const Mime = @This();
+content_type: ContentType,
+params: []const u8 = "",
+// IANA defines max. charset value length as 40.
+// We keep 41 for null-termination since HTML parser expects in this format.
+charset: [41]u8 = default_charset,
+
+/// String "UTF-8" continued by null characters.
+pub const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
+
+/// Mime with unknown Content-Type, empty params and empty charset.
+pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
+
+pub const ContentTypeEnum = enum {
+ text_xml,
+ text_html,
+ text_javascript,
+ text_plain,
+ text_css,
+ application_json,
+ unknown,
+ other,
+};
+
+pub const ContentType = union(ContentTypeEnum) {
+ text_xml: void,
+ text_html: void,
+ text_javascript: void,
+ text_plain: void,
+ text_css: void,
+ application_json: void,
+ unknown: void,
+ other: struct { type: []const u8, sub_type: []const u8 },
+};
+
+/// Returns the null-terminated charset value.
+pub fn charsetString(mime: *const Mime) [:0]const u8 {
+ return @ptrCast(&mime.charset);
+}
+
+/// Removes quotes of value if quotes are given.
+///
+/// Currently we don't validate the charset.
+/// See section 2.3 Naming Requirements:
+/// https://datatracker.ietf.org/doc/rfc2978/
+fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
+ // Cannot be larger than 40.
+ // https://datatracker.ietf.org/doc/rfc2978/
+ if (value.len > 40) return error.CharsetTooBig;
+
+ // If the first char is a quote, look for a pair.
+ if (value[0] == '"') {
+ if (value.len < 3 or value[value.len - 1] != '"') {
+ return error.Invalid;
+ }
+
+ return value[1 .. value.len - 1];
+ }
+
+ // No quotes.
+ return value;
+}
+
+pub fn parse(input: []u8) !Mime {
+ if (input.len > 255) {
+ return error.TooBig;
+ }
+
+ // Zig's trim API is broken. The return type is always `[]const u8`,
+ // even if the input type is `[]u8`. @constCast is safe here.
+ var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
+ _ = std.ascii.lowerString(normalized, normalized);
+
+ const content_type, const type_len = try parseContentType(normalized);
+ if (type_len >= normalized.len) {
+ return .{ .content_type = content_type };
+ }
+
+ const params = trimLeft(normalized[type_len..]);
+
+ var charset: [41]u8 = undefined;
+
+ var it = std.mem.splitScalar(u8, params, ';');
+ while (it.next()) |attr| {
+ const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid;
+ const name = trimLeft(attr[0..i]);
+
+ const value = trimRight(attr[i + 1 ..]);
+ if (value.len == 0) {
+ return error.Invalid;
+ }
+
+ const attribute_name = std.meta.stringToEnum(enum {
+ charset,
+ }, name) orelse continue;
+
+ switch (attribute_name) {
+ .charset => {
+ if (value.len == 0) {
+ break;
+ }
+
+ const attribute_value = try parseCharset(value);
+ @memcpy(charset[0..attribute_value.len], attribute_value);
+ // Null-terminate right after attribute value.
+ charset[attribute_value.len] = 0;
+ },
+ }
+ }
+
+ return .{
+ .params = params,
+ .charset = charset,
+ .content_type = content_type,
+ };
+}
+
+pub fn sniff(body: []const u8) ?Mime {
+ // 0x0C is form feed
+ const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
+ if (content.len == 0) {
+ return null;
+ }
+
+ if (content[0] != '<') {
+ if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
+ // UTF-8 BOM
+ return .{ .content_type = .{ .text_plain = {} } };
+ }
+ if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
+ // UTF-16 big-endian BOM
+ return .{ .content_type = .{ .text_plain = {} } };
+ }
+ if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
+ // UTF-16 little-endian BOM
+ return .{ .content_type = .{ .text_plain = {} } };
+ }
+ return null;
+ }
+
+ // The longest prefix we have is " known_prefix.len) {
+ const next = prefix[known_prefix.len];
+ // a "tag-terminating-byte"
+ if (next == ' ' or next == '>') {
+ return .{ .content_type = kp.@"1" };
+ }
+ }
+ }
+
+ return null;
+}
+
+pub fn isHTML(self: *const Mime) bool {
+ return self.content_type == .text_html;
+}
+
+// we expect value to be lowercase
+fn parseContentType(value: []const u8) !struct { ContentType, usize } {
+ const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
+ const type_name = trimRight(value[0..end]);
+ const attribute_start = end + 1;
+
+ if (std.meta.stringToEnum(enum {
+ @"text/xml",
+ @"text/html",
+ @"text/css",
+ @"text/plain",
+
+ @"text/javascript",
+ @"application/javascript",
+ @"application/x-javascript",
+
+ @"application/json",
+ }, type_name)) |known_type| {
+ const ct: ContentType = switch (known_type) {
+ .@"text/xml" => .{ .text_xml = {} },
+ .@"text/html" => .{ .text_html = {} },
+ .@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
+ .@"text/plain" => .{ .text_plain = {} },
+ .@"text/css" => .{ .text_css = {} },
+ .@"application/json" => .{ .application_json = {} },
+ };
+ return .{ ct, attribute_start };
+ }
+
+ const separator = std.mem.indexOfScalarPos(u8, type_name, 0, '/') orelse return error.Invalid;
+
+ const main_type = value[0..separator];
+ const sub_type = trimRight(value[separator + 1 .. end]);
+
+ if (main_type.len == 0 or validType(main_type) == false) {
+ return error.Invalid;
+ }
+ if (sub_type.len == 0 or validType(sub_type) == false) {
+ return error.Invalid;
+ }
+
+ return .{ .{ .other = .{
+ .type = main_type,
+ .sub_type = sub_type,
+ } }, attribute_start };
+}
+
+const T_SPECIAL = blk: {
+ var v = [_]bool{false} ** 256;
+ for ("()<>@,;:\\\"/[]?=") |b| {
+ v[b] = true;
+ }
+ break :blk v;
+};
+
+const VALID_CODEPOINTS = blk: {
+ var v: [256]bool = undefined;
+ for (0..256) |i| {
+ v[i] = std.ascii.isAlphanumeric(i);
+ }
+ for ("!#$%&\\*+-.^'_`|~") |b| {
+ v[b] = true;
+ }
+ break :blk v;
+};
+
+fn validType(value: []const u8) bool {
+ for (value) |b| {
+ if (VALID_CODEPOINTS[b] == false) {
+ return false;
+ }
+ }
+ return true;
+}
+
+fn trimLeft(s: []const u8) []const u8 {
+ return std.mem.trimLeft(u8, s, &std.ascii.whitespace);
+}
+
+fn trimRight(s: []const u8) []const u8 {
+ return std.mem.trimRight(u8, s, &std.ascii.whitespace);
+}
+
+const testing = @import("../testing.zig");
+test "Mime: invalid" {
+ defer testing.reset();
+
+ const invalids = [_][]const u8{
+ "",
+ "text",
+ "text /html",
+ "text/ html",
+ "text / html",
+ "text/html other",
+ "text/html; x",
+ "text/html; x=",
+ "text/html; x= ",
+ "text/html; = ",
+ "text/html;=",
+ "text/html; charset=\"\"",
+ "text/html; charset=\"",
+ "text/html; charset=\"\\",
+ };
+
+ for (invalids) |invalid| {
+ const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
+ try testing.expectError(error.Invalid, Mime.parse(mutable_input));
+ }
+}
+
+test "Mime: parse common" {
+ defer testing.reset();
+
+ try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml");
+ try expect(.{ .content_type = .{ .text_html = {} } }, "text/html");
+ try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain");
+
+ try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml;");
+ try expect(.{ .content_type = .{ .text_html = {} } }, "text/html;");
+ try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain;");
+
+ try expect(.{ .content_type = .{ .text_xml = {} } }, " \ttext/xml");
+ try expect(.{ .content_type = .{ .text_html = {} } }, "text/html ");
+ try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain \t\t");
+
+ try expect(.{ .content_type = .{ .text_xml = {} } }, "TEXT/xml");
+ try expect(.{ .content_type = .{ .text_html = {} } }, "text/Html");
+ try expect(.{ .content_type = .{ .text_plain = {} } }, "TEXT/PLAIN");
+
+ try expect(.{ .content_type = .{ .text_xml = {} } }, " TeXT/xml");
+ try expect(.{ .content_type = .{ .text_html = {} } }, "teXt/HtML ;");
+ try expect(.{ .content_type = .{ .text_plain = {} } }, "tExT/PlAiN;");
+
+ try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript");
+ try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript");
+ try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript");
+
+ try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
+ try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
+}
+
+test "Mime: parse uncommon" {
+ defer testing.reset();
+
+ const text_csv = Expectation{
+ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } },
+ };
+ try expect(text_csv, "text/csv");
+ try expect(text_csv, "text/csv;");
+ try expect(text_csv, " text/csv\t ");
+ try expect(text_csv, " text/csv\t ;");
+
+ try expect(
+ .{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } },
+ "Text/CSV",
+ );
+}
+
+test "Mime: parse charset" {
+ defer testing.reset();
+
+ try expect(.{
+ .content_type = .{ .text_xml = {} },
+ .charset = "utf-8",
+ .params = "charset=utf-8",
+ }, "text/xml; charset=utf-8");
+
+ try expect(.{
+ .content_type = .{ .text_xml = {} },
+ .charset = "utf-8",
+ .params = "charset=\"utf-8\"",
+ }, "text/xml;charset=\"UTF-8\"");
+
+ try expect(.{
+ .content_type = .{ .text_html = {} },
+ .charset = "iso-8859-1",
+ .params = "charset=\"iso-8859-1\"",
+ }, "text/html; charset=\"iso-8859-1\"");
+
+ try expect(.{
+ .content_type = .{ .text_html = {} },
+ .charset = "iso-8859-1",
+ .params = "charset=\"iso-8859-1\"",
+ }, "text/html; charset=\"ISO-8859-1\"");
+
+ try expect(.{
+ .content_type = .{ .text_xml = {} },
+ .charset = "custom-non-standard-charset-value",
+ .params = "charset=\"custom-non-standard-charset-value\"",
+ }, "text/xml;charset=\"custom-non-standard-charset-value\"");
+}
+
+test "Mime: isHTML" {
+ defer testing.reset();
+
+ const assert = struct {
+ fn assert(expected: bool, input: []const u8) !void {
+ const mutable_input = try testing.arena_allocator.dupe(u8, input);
+ var mime = try Mime.parse(mutable_input);
+ try testing.expectEqual(expected, mime.isHTML());
+ }
+ }.assert;
+ try assert(true, "text/html");
+ try assert(true, "text/html;");
+ try assert(true, "text/html; charset=utf-8");
+ try assert(false, "text/htm"); // htm not html
+ try assert(false, "text/plain");
+ try assert(false, "over/9000");
+}
+
+test "Mime: sniff" {
+ try testing.expectEqual(null, Mime.sniff(""));
+ try testing.expectEqual(null, Mime.sniff(""));
+ try testing.expectEqual(null, Mime.sniff("\n "));
+ try testing.expectEqual(null, Mime.sniff("\n \t "));
+
+ const expectHTML = struct {
+ fn expect(input: []const u8) !void {
+ try testing.expectEqual(.text_html, std.meta.activeTag(Mime.sniff(input).?.content_type));
+ }
+ }.expect;
+
+ try expectHTML(" even more stufff");
+
+ try expectHTML("");
+
+ try expectHTML("
+
+
diff --git a/src/browser/tests/mutation_observer/attribute_filter.html b/src/browser/tests/mutation_observer/attribute_filter.html
new file mode 100644
index 000000000..eb1c0605f
--- /dev/null
+++ b/src/browser/tests/mutation_observer/attribute_filter.html
@@ -0,0 +1,157 @@
+
+Test
+Test
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/character_data.html b/src/browser/tests/mutation_observer/character_data.html
new file mode 100644
index 000000000..260d28228
--- /dev/null
+++ b/src/browser/tests/mutation_observer/character_data.html
@@ -0,0 +1,79 @@
+
+Initial text
+Test
+Test
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/childlist.html b/src/browser/tests/mutation_observer/childlist.html
new file mode 100644
index 000000000..e15eaa35a
--- /dev/null
+++ b/src/browser/tests/mutation_observer/childlist.html
@@ -0,0 +1,327 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/multiple_observers.html b/src/browser/tests/mutation_observer/multiple_observers.html
new file mode 100644
index 000000000..850aee649
--- /dev/null
+++ b/src/browser/tests/mutation_observer/multiple_observers.html
@@ -0,0 +1,47 @@
+
+Test
+
+
+
diff --git a/src/browser/tests/mutation_observer/mutation_observer.html b/src/browser/tests/mutation_observer/mutation_observer.html
new file mode 100644
index 000000000..76b95bb7f
--- /dev/null
+++ b/src/browser/tests/mutation_observer/mutation_observer.html
@@ -0,0 +1,114 @@
+
+Test
+Test
+Test
+Test
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/mutation_observer/mutations_during_callback.html b/src/browser/tests/mutation_observer/mutations_during_callback.html
new file mode 100644
index 000000000..938e45183
--- /dev/null
+++ b/src/browser/tests/mutation_observer/mutations_during_callback.html
@@ -0,0 +1,39 @@
+
+Test
+
+
+
diff --git a/src/browser/tests/mutation_observer/observe_multiple_targets.html b/src/browser/tests/mutation_observer/observe_multiple_targets.html
new file mode 100644
index 000000000..c45475267
--- /dev/null
+++ b/src/browser/tests/mutation_observer/observe_multiple_targets.html
@@ -0,0 +1,31 @@
+
+Test1
+Test2
+
+
+
diff --git a/src/browser/tests/mutation_observer/reobserve_same_target.html b/src/browser/tests/mutation_observer/reobserve_same_target.html
new file mode 100644
index 000000000..9249fbaca
--- /dev/null
+++ b/src/browser/tests/mutation_observer/reobserve_same_target.html
@@ -0,0 +1,27 @@
+
+Test
+
+
+
diff --git a/src/browser/tests/mutation_observer/subtree.html b/src/browser/tests/mutation_observer/subtree.html
new file mode 100644
index 000000000..6492a20e8
--- /dev/null
+++ b/src/browser/tests/mutation_observer/subtree.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/form_data.html b/src/browser/tests/net/form_data.html
new file mode 100644
index 000000000..71aae77cb
--- /dev/null
+++ b/src/browser/tests/net/form_data.html
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/headers.html b/src/browser/tests/net/headers.html
new file mode 100644
index 000000000..efa4f48bb
--- /dev/null
+++ b/src/browser/tests/net/headers.html
@@ -0,0 +1,336 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/request.html b/src/browser/tests/net/request.html
new file mode 100644
index 000000000..437c26301
--- /dev/null
+++ b/src/browser/tests/net/request.html
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html
new file mode 100644
index 000000000..54b66b3d3
--- /dev/null
+++ b/src/browser/tests/net/url_search_params.html
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/net/xhr.html b/src/browser/tests/net/xhr.html
new file mode 100644
index 000000000..bb73aa735
--- /dev/null
+++ b/src/browser/tests/net/xhr.html
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/src/browser/tests/node/append_child.html b/src/browser/tests/node/append_child.html
new file mode 100644
index 000000000..0736fa039
--- /dev/null
+++ b/src/browser/tests/node/append_child.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/node/child_nodes.html b/src/browser/tests/node/child_nodes.html
new file mode 100644
index 000000000..3a6b67974
--- /dev/null
+++ b/src/browser/tests/node/child_nodes.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/clone_node.html b/src/browser/tests/node/clone_node.html
new file mode 100644
index 000000000..d9945d7c9
--- /dev/null
+++ b/src/browser/tests/node/clone_node.html
@@ -0,0 +1,292 @@
+
+
+
+
Paragraph 1
+
Paragraph 2
+
Some text
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/compare_document_position.html b/src/browser/tests/node/compare_document_position.html
new file mode 100644
index 000000000..dd8b7804f
--- /dev/null
+++ b/src/browser/tests/node/compare_document_position.html
@@ -0,0 +1,259 @@
+
+
+
+
+
+Unrelated
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/insert_before.html b/src/browser/tests/node/insert_before.html
new file mode 100644
index 000000000..8be48e563
--- /dev/null
+++ b/src/browser/tests/node/insert_before.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/node/is_equal_node.html b/src/browser/tests/node/is_equal_node.html
new file mode 100644
index 000000000..ef192fae2
--- /dev/null
+++ b/src/browser/tests/node/is_equal_node.html
@@ -0,0 +1,40 @@
+
+
+
+
+ we're no strangers to love
+ you know the rules
+
+ and so do I
+
+
+
+ we're no strangers to love
+ you know the rules
+
+ and so do I
+
+
+
diff --git a/src/browser/tests/node/node.html b/src/browser/tests/node/node.html
new file mode 100644
index 000000000..72c51748e
--- /dev/null
+++ b/src/browser/tests/node/node.html
@@ -0,0 +1,212 @@
+
+
+9000!!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/node_iterator.html b/src/browser/tests/node/node_iterator.html
new file mode 100644
index 000000000..992df5ad9
--- /dev/null
+++ b/src/browser/tests/node/node_iterator.html
@@ -0,0 +1,473 @@
+
+
+
+
+
+
+ Text 1
+ Text 2
+
+
+ Text 3
+
+
Paragraph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/normalize.html b/src/browser/tests/node/normalize.html
new file mode 100644
index 000000000..ead599a6b
--- /dev/null
+++ b/src/browser/tests/node/normalize.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+"puppeteer "
+ Leto
+
+
+ Atreides
+
diff --git a/src/browser/tests/node/remove_child.html b/src/browser/tests/node/remove_child.html
new file mode 100644
index 000000000..fdf0b813d
--- /dev/null
+++ b/src/browser/tests/node/remove_child.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/node/replace_child.html b/src/browser/tests/node/replace_child.html
new file mode 100644
index 000000000..deea60a8b
--- /dev/null
+++ b/src/browser/tests/node/replace_child.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/node/text_content.html b/src/browser/tests/node/text_content.html
new file mode 100644
index 000000000..fc6a0de9a
--- /dev/null
+++ b/src/browser/tests/node/text_content.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ This is a
+ text
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/node/tree.html b/src/browser/tests/node/tree.html
new file mode 100644
index 000000000..d2a3e63bb
--- /dev/null
+++ b/src/browser/tests/node/tree.html
@@ -0,0 +1,25 @@
+
+
+
+
diff --git a/src/browser/tests/node/tree_walker.html b/src/browser/tests/node/tree_walker.html
new file mode 100644
index 000000000..2e9653a35
--- /dev/null
+++ b/src/browser/tests/node/tree_walker.html
@@ -0,0 +1,385 @@
+
+
+
+
+
+
+ Text 1
+ Text 2
+
+
+ Text 3
+
+
Paragraph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/page/load_event.html b/src/browser/tests/page/load_event.html
new file mode 100644
index 000000000..1776313c4
--- /dev/null
+++ b/src/browser/tests/page/load_event.html
@@ -0,0 +1,18 @@
+
+
+
diff --git a/src/browser/tests/page/meta.html b/src/browser/tests/page/meta.html
new file mode 100644
index 000000000..80647a879
--- /dev/null
+++ b/src/browser/tests/page/meta.html
@@ -0,0 +1,42 @@
+
+
+
diff --git a/src/browser/tests/page/mod1.js b/src/browser/tests/page/mod1.js
new file mode 100644
index 000000000..374c0e452
--- /dev/null
+++ b/src/browser/tests/page/mod1.js
@@ -0,0 +1,2 @@
+const val1 = 'value-1';
+export { val1 as "val1" }
diff --git a/src/browser/tests/page/module.html b/src/browser/tests/page/module.html
new file mode 100644
index 000000000..1dd797944
--- /dev/null
+++ b/src/browser/tests/page/module.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/page/modules/base.js b/src/browser/tests/page/modules/base.js
new file mode 100644
index 000000000..aac2ce466
--- /dev/null
+++ b/src/browser/tests/page/modules/base.js
@@ -0,0 +1 @@
+export const baseValue = 'from-base';
diff --git a/src/browser/tests/page/modules/circular-a.js b/src/browser/tests/page/modules/circular-a.js
new file mode 100644
index 000000000..e6f79f6ba
--- /dev/null
+++ b/src/browser/tests/page/modules/circular-a.js
@@ -0,0 +1,7 @@
+import { getBValue } from './circular-b.js';
+
+export const aValue = 'a';
+
+export function getFromB() {
+ return getBValue();
+}
diff --git a/src/browser/tests/page/modules/circular-b.js b/src/browser/tests/page/modules/circular-b.js
new file mode 100644
index 000000000..36902a6a4
--- /dev/null
+++ b/src/browser/tests/page/modules/circular-b.js
@@ -0,0 +1,11 @@
+import { aValue } from './circular-a.js';
+
+export const bValue = 'b';
+
+export function getBValue() {
+ return bValue;
+}
+
+export function getFromA() {
+ return aValue;
+}
diff --git a/src/browser/tests/page/modules/dynamic-chain-a.js b/src/browser/tests/page/modules/dynamic-chain-a.js
new file mode 100644
index 000000000..3b6c68053
--- /dev/null
+++ b/src/browser/tests/page/modules/dynamic-chain-a.js
@@ -0,0 +1,6 @@
+export async function loadChain() {
+ const b = await import('./dynamic-chain-b.js');
+ return b.loadNext();
+}
+
+export const chainValue = 'chain-a';
diff --git a/src/browser/tests/page/modules/dynamic-chain-b.js b/src/browser/tests/page/modules/dynamic-chain-b.js
new file mode 100644
index 000000000..89f347a0f
--- /dev/null
+++ b/src/browser/tests/page/modules/dynamic-chain-b.js
@@ -0,0 +1,6 @@
+export async function loadNext() {
+ const c = await import('./dynamic-chain-c.js');
+ return c.finalValue;
+}
+
+export const chainValue = 'chain-b';
diff --git a/src/browser/tests/page/modules/dynamic-chain-c.js b/src/browser/tests/page/modules/dynamic-chain-c.js
new file mode 100644
index 000000000..574de55d3
--- /dev/null
+++ b/src/browser/tests/page/modules/dynamic-chain-c.js
@@ -0,0 +1 @@
+export const finalValue = 'chain-end';
diff --git a/src/browser/tests/page/modules/dynamic-circular-x.js b/src/browser/tests/page/modules/dynamic-circular-x.js
new file mode 100644
index 000000000..59a4fe0f2
--- /dev/null
+++ b/src/browser/tests/page/modules/dynamic-circular-x.js
@@ -0,0 +1,6 @@
+export const xValue = 'dynamic-x';
+
+export async function loadY() {
+ const y = await import('./dynamic-circular-y.js');
+ return y.yValue;
+}
diff --git a/src/browser/tests/page/modules/dynamic-circular-y.js b/src/browser/tests/page/modules/dynamic-circular-y.js
new file mode 100644
index 000000000..c3b97e487
--- /dev/null
+++ b/src/browser/tests/page/modules/dynamic-circular-y.js
@@ -0,0 +1,6 @@
+export const yValue = 'dynamic-y';
+
+export async function loadX() {
+ const x = await import('./dynamic-circular-x.js');
+ return x.xValue;
+}
diff --git a/src/browser/tests/page/modules/importer.js b/src/browser/tests/page/modules/importer.js
new file mode 100644
index 000000000..c2351a8b4
--- /dev/null
+++ b/src/browser/tests/page/modules/importer.js
@@ -0,0 +1,4 @@
+import { baseValue } from './base.js';
+
+export const importedValue = baseValue;
+export const localValue = 'local';
diff --git a/src/browser/tests/page/modules/mixed-circular-dynamic.js b/src/browser/tests/page/modules/mixed-circular-dynamic.js
new file mode 100644
index 000000000..64c60d84d
--- /dev/null
+++ b/src/browser/tests/page/modules/mixed-circular-dynamic.js
@@ -0,0 +1,7 @@
+import { staticValue } from './mixed-circular-static.js';
+
+export const dynamicValue = 'dynamic-side';
+
+export function getStaticValue() {
+ return staticValue;
+}
diff --git a/src/browser/tests/page/modules/mixed-circular-static.js b/src/browser/tests/page/modules/mixed-circular-static.js
new file mode 100644
index 000000000..5fc8f4667
--- /dev/null
+++ b/src/browser/tests/page/modules/mixed-circular-static.js
@@ -0,0 +1,6 @@
+export const staticValue = 'static-side';
+
+export async function loadDynamicSide() {
+ const dynamic = await import('./mixed-circular-dynamic.js');
+ return dynamic.dynamicValue;
+}
diff --git a/src/browser/tests/page/modules/re-exporter.js b/src/browser/tests/page/modules/re-exporter.js
new file mode 100644
index 000000000..1d882b10a
--- /dev/null
+++ b/src/browser/tests/page/modules/re-exporter.js
@@ -0,0 +1,2 @@
+export { baseValue } from './base.js';
+export { importedValue, localValue } from './importer.js';
diff --git a/src/browser/tests/page/modules/shared.js b/src/browser/tests/page/modules/shared.js
new file mode 100644
index 000000000..4603c3b60
--- /dev/null
+++ b/src/browser/tests/page/modules/shared.js
@@ -0,0 +1,9 @@
+let counter = 0;
+
+export function increment() {
+ return ++counter;
+}
+
+export function getCount() {
+ return counter;
+}
diff --git a/src/browser/tests/page/modules/syntax-error.js b/src/browser/tests/page/modules/syntax-error.js
new file mode 100644
index 000000000..106b0bb0c
--- /dev/null
+++ b/src/browser/tests/page/modules/syntax-error.js
@@ -0,0 +1,2 @@
+export const value = 'test'
+this is a syntax error!
diff --git a/src/browser/tests/page/modules/test-404.js b/src/browser/tests/page/modules/test-404.js
new file mode 100644
index 000000000..caa2679c3
--- /dev/null
+++ b/src/browser/tests/page/modules/test-404.js
@@ -0,0 +1,2 @@
+import { something } from './nonexistent.js';
+export { something };
diff --git a/src/browser/tests/page/modules/test-syntax-error.js b/src/browser/tests/page/modules/test-syntax-error.js
new file mode 100644
index 000000000..04b703eee
--- /dev/null
+++ b/src/browser/tests/page/modules/test-syntax-error.js
@@ -0,0 +1,2 @@
+import { value } from './syntax-error.js';
+export { value };
diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html
new file mode 100644
index 000000000..5928bba93
--- /dev/null
+++ b/src/browser/tests/performance.html
@@ -0,0 +1,282 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/polyfill/webcomponents.html b/src/browser/tests/polyfill/webcomponents.html
similarity index 100%
rename from src/tests/polyfill/webcomponents.html
rename to src/browser/tests/polyfill/webcomponents.html
diff --git a/src/browser/tests/range.html b/src/browser/tests/range.html
new file mode 100644
index 000000000..92237a257
--- /dev/null
+++ b/src/browser/tests/range.html
@@ -0,0 +1,377 @@
+
+
+
+
+
First paragraph
+
Second paragraph
+
Span content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/basic.html b/src/browser/tests/shadowroot/basic.html
new file mode 100644
index 000000000..ed82ab18b
--- /dev/null
+++ b/src/browser/tests/shadowroot/basic.html
@@ -0,0 +1,156 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/custom_elements.html b/src/browser/tests/shadowroot/custom_elements.html
new file mode 100644
index 000000000..32a7ee316
--- /dev/null
+++ b/src/browser/tests/shadowroot/custom_elements.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/dom_traversal.html b/src/browser/tests/shadowroot/dom_traversal.html
new file mode 100644
index 000000000..a1b7c7c7a
--- /dev/null
+++ b/src/browser/tests/shadowroot/dom_traversal.html
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/dump.html b/src/browser/tests/shadowroot/dump.html
new file mode 100644
index 000000000..57544393c
--- /dev/null
+++ b/src/browser/tests/shadowroot/dump.html
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/edge_cases.html b/src/browser/tests/shadowroot/edge_cases.html
new file mode 100644
index 000000000..3412e1109
--- /dev/null
+++ b/src/browser/tests/shadowroot/edge_cases.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/events.html b/src/browser/tests/shadowroot/events.html
new file mode 100644
index 000000000..de6f7cdc1
--- /dev/null
+++ b/src/browser/tests/shadowroot/events.html
@@ -0,0 +1,186 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/id_collision.html b/src/browser/tests/shadowroot/id_collision.html
new file mode 100644
index 000000000..d83892493
--- /dev/null
+++ b/src/browser/tests/shadowroot/id_collision.html
@@ -0,0 +1,55 @@
+
+
+
+Document
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/id_management.html b/src/browser/tests/shadowroot/id_management.html
new file mode 100644
index 000000000..0d498d3ef
--- /dev/null
+++ b/src/browser/tests/shadowroot/id_management.html
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/innerHTML_spec.html b/src/browser/tests/shadowroot/innerHTML_spec.html
new file mode 100644
index 000000000..029f0e7af
--- /dev/null
+++ b/src/browser/tests/shadowroot/innerHTML_spec.html
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/shadowroot/scoping.html b/src/browser/tests/shadowroot/scoping.html
new file mode 100644
index 000000000..37caaeb12
--- /dev/null
+++ b/src/browser/tests/shadowroot/scoping.html
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/storage.html b/src/browser/tests/storage.html
new file mode 100644
index 000000000..edd90979d
--- /dev/null
+++ b/src/browser/tests/storage.html
@@ -0,0 +1,62 @@
+
+
+
diff --git a/src/browser/tests/streams/readable_stream.html b/src/browser/tests/streams/readable_stream.html
new file mode 100644
index 000000000..3d00d6cfc
--- /dev/null
+++ b/src/browser/tests/streams/readable_stream.html
@@ -0,0 +1,303 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/testing.js b/src/browser/tests/testing.js
new file mode 100644
index 000000000..5cb6220a0
--- /dev/null
+++ b/src/browser/tests/testing.js
@@ -0,0 +1,225 @@
+(() => {
+ let failed = false;
+ let observed_ids = {};
+ let eventuallies = [];
+ let async_capture = null;
+ let current_script_id = null;
+
+ function expectTrue(actual) {
+ expectEqual(true, actual);
+ }
+
+ function expectFalse(actual) {
+ expectEqual(false, actual);
+ }
+
+ function expectEqual(expected, actual, opts) {
+ if (_equal(expected, actual)) {
+ _registerObservation('ok', opts);
+ return;
+ }
+ failed = true;
+ _registerObservation('fail', opts);
+ let err = `expected: ${_displayValue(expected)}, got: ${_displayValue(actual)}\n script_id: ${_currentScriptId()}`;
+ if (async_capture) {
+ err += `\n stack: ${async_capture.stack}`;
+ }
+ console.error(err);
+ throw new Error('expectEqual failed');
+ }
+
+ function fail(reason) {
+ failed = true;
+ console.error(reason);
+ throw new Error('testing.fail()');
+ }
+
+ function expectError(expected, fn) {
+ withError((err) => {
+ expectEqual(expected, err.toString());
+ }, fn);
+ }
+
+ function withError(cb, fn) {
+ try{
+ fn();
+ } catch (err) {
+ cb(err);
+ return;
+ }
+
+ console.error(`expected error but no error received\n`);
+ throw new Error('no error');
+ }
+
+ function eventually(cb) {
+ const script_id = _currentScriptId();
+ if (!script_id) {
+ throw new Error('testing.eventually called outside of a script');
+ }
+ eventuallies.push({
+ callback: cb,
+ script_id: script_id,
+ });
+ }
+
+ async function async(cb) {
+ const script_id = document.currentScript.id;
+ const stack = new Error().stack;
+ async_capture = {script_id: script_id, stack: stack};
+ await cb();
+ async_capture = null;
+ }
+
+ function assertOk() {
+ if (failed) {
+ throw new Error('Failed');
+ }
+
+ for (let e of eventuallies) {
+ current_script_id = e.script_id;
+ e.callback();
+ current_script_id = null;
+ }
+
+ const script_ids = Object.keys(observed_ids);
+ if (script_ids.length === 0) {
+ throw new Error('no test observations were recorded');
+ }
+
+ const scripts = document.getElementsByTagName('script');
+ for (let script of scripts) {
+ const script_id = script.id;
+ if (!script_id) {
+ continue;
+ }
+
+ const status = observed_ids[script_id];
+ if (status !== 'ok') {
+ throw new Error(`script id: '${script_id}' failed: ${status || 'no assertions'}`);
+ }
+ }
+ }
+
+ window.testing = {
+ fail: fail,
+ async: async,
+ assertOk: assertOk,
+ expectTrue: expectTrue,
+ expectFalse: expectFalse,
+ expectEqual: expectEqual,
+ expectError: expectError,
+ withError: withError,
+ eventually: eventually,
+ todo: function(){},
+ };
+
+ window.$ = function(sel) {
+ return document.querySelector(sel);
+ }
+
+ window.$$ = function(sel) {
+ return document.querySelectorAll(sel);
+ }
+
+ function _equal(expected, actual) {
+ if (expected === actual) {
+ return true;
+ }
+ if (expected === null || actual === null) {
+ return false;
+ }
+ if (typeof expected !== 'object' || typeof actual !== 'object') {
+ return false;
+ }
+
+ if (Object.keys(expected).length != Object.keys(actual).length) {
+ return false;
+ }
+
+ if (expected instanceof Node) {
+ if (!(actual instanceof Node)) {
+ return false;
+ }
+ return expected.isSameNode(actual);
+ }
+
+ for (property in expected) {
+ if (actual.hasOwnProperty(property) === false) {
+ return false;
+ }
+ if (_equal(expected[property], actual[property]) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ function _registerObservation(status, opts) {
+ script_id = opts?.script_id || _currentScriptId();
+ if (!script_id) {
+ return;
+ }
+ if (observed_ids[script_id] === 'fail') {
+ return;
+ }
+
+ observed_ids[script_id] = status;
+
+ if (document.currentScript != null) {
+ if (document.currentScript.onerror === null) {
+ document.currentScript.onerror = function() {
+ observed_ids[document.currentScript.id] = 'fail';
+ failed = true;
+ }
+ }
+ }
+ }
+
+ function _currentScriptId() {
+ if (current_script_id) {
+ return current_script_id;
+ }
+
+ if (async_capture) {
+ return async_capture.script_id;
+ }
+
+ const current_script = document.currentScript;
+
+ if (!current_script) {
+ return null;
+ }
+ return current_script.id;
+ }
+
+ function _displayValue(value) {
+ if (value instanceof Element) {
+ return `HTMLElement: ${value.outerHTML}`;
+ }
+ if (value instanceof Attr) {
+ return `Attribute: ${value.name}: ${value.value}`;
+ }
+ if (value instanceof Node) {
+ return value.nodeName;
+ }
+ if (value === window) {
+ return '#window';
+ }
+ if (value instanceof Array) {
+ return `array: \n${value.map(_displayValue).join('\n')}\n`;
+ }
+
+ const seen = [];
+ return JSON.stringify(value, function(key, val) {
+ if (val != null && typeof val == "object") {
+ if (seen.indexOf(val) >= 0) {
+ return;
+ }
+ seen.push(val);
+ }
+ return val;
+ });
+ }
+})();
diff --git a/src/browser/tests/url.html b/src/browser/tests/url.html
new file mode 100644
index 000000000..80b708232
--- /dev/null
+++ b/src/browser/tests/url.html
@@ -0,0 +1,658 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/window/body_onload1.html b/src/browser/tests/window/body_onload1.html
new file mode 100644
index 000000000..7eb7ee611
--- /dev/null
+++ b/src/browser/tests/window/body_onload1.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/window/body_onload2.html b/src/browser/tests/window/body_onload2.html
new file mode 100644
index 000000000..32327c933
--- /dev/null
+++ b/src/browser/tests/window/body_onload2.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/window/location.html b/src/browser/tests/window/location.html
new file mode 100644
index 000000000..b61808e0c
--- /dev/null
+++ b/src/browser/tests/window/location.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/window/navigator.html b/src/browser/tests/window/navigator.html
new file mode 100644
index 000000000..11ad9adec
--- /dev/null
+++ b/src/browser/tests/window/navigator.html
@@ -0,0 +1,29 @@
+
+
+
+
diff --git a/src/browser/tests/window/report_error.html b/src/browser/tests/window/report_error.html
new file mode 100644
index 000000000..c2d66125a
--- /dev/null
+++ b/src/browser/tests/window/report_error.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/window/screen.html b/src/browser/tests/window/screen.html
new file mode 100644
index 000000000..5239ba434
--- /dev/null
+++ b/src/browser/tests/window/screen.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/src/browser/tests/window/timers.html b/src/browser/tests/window/timers.html
new file mode 100644
index 000000000..a21a3f412
--- /dev/null
+++ b/src/browser/tests/window/timers.html
@@ -0,0 +1,24 @@
+
+
+
+
diff --git a/src/browser/tests/window/window.html b/src/browser/tests/window/window.html
new file mode 100644
index 000000000..9cd74b371
--- /dev/null
+++ b/src/browser/tests/window/window.html
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/tests/xmlserializer.html b/src/browser/tests/xmlserializer.html
new file mode 100644
index 000000000..edbc60c88
--- /dev/null
+++ b/src/browser/tests/xmlserializer.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/url/url.zig b/src/browser/url/url.zig
deleted file mode 100644
index 08c97bf61..000000000
--- a/src/browser/url/url.zig
+++ /dev/null
@@ -1,516 +0,0 @@
-// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
-//
-// Francis Bouvier
-// Pierre Tachoire
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU Affero General Public License as
-// published by the Free Software Foundation, either version 3 of the
-// License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU Affero General Public License for more details.
-//
-// You should have received a copy of the GNU Affero General Public License
-// along with this program. If not, see .
-
-const std = @import("std");
-const Allocator = std.mem.Allocator;
-
-const js = @import("../js/js.zig");
-const parser = @import("../netsurf.zig");
-const Page = @import("../page.zig").Page;
-const FormData = @import("../xhr/form_data.zig").FormData;
-
-const kv = @import("../key_value.zig");
-const iterator = @import("../iterator/iterator.zig");
-
-pub const Interfaces = .{
- URL,
- URLSearchParams,
- KeyIterable,
- ValueIterable,
- EntryIterable,
-};
-
-// https://url.spec.whatwg.org/#url
-//
-// TODO we could avoid many of these getter string allocatoration in two differents
-// way:
-//
-// 1. We can eventually get the slice of scheme *with* the following char in
-// the underlying string. But I don't know if it's possible and how to do that.
-// I mean, if the rawuri contains `https://foo.bar`, uri.scheme is a slice
-// containing only `https`. I want `https:` so, in theory, I don't need to
-// allocatorate data, I should be able to retrieve the scheme + the following `:`
-// from rawuri.
-//
-// 2. The other way would be to copy the `std.Uri` code to have a dedicated
-// parser including the characters we want for the web API.
-pub const URL = struct {
- uri: std.Uri,
- search_params: URLSearchParams,
-
- pub const empty = URL{
- .uri = .{ .scheme = "" },
- .search_params = .{},
- };
-
- const URLArg = union(enum) {
- url: *URL,
- element: *parser.ElementHTML,
- string: []const u8,
-
- fn toString(self: URLArg, arena: Allocator) !?[]const u8 {
- switch (self) {
- .string => |s| return s,
- .url => |url| return try url.toString(arena),
- .element => |e| return try parser.elementGetAttribute(@ptrCast(e), "href"),
- }
- }
- };
-
- pub fn constructor(url: URLArg, base: ?URLArg, page: *Page) !URL {
- const arena = page.arena;
- const url_str = try url.toString(arena) orelse return error.InvalidArgument;
-
- var raw: ?[]const u8 = null;
- if (base) |b| {
- if (try b.toString(arena)) |bb| {
- raw = try @import("../../url.zig").URL.stitch(arena, url_str, bb, .{});
- }
- }
-
- if (raw == null) {
- // if it was a URL, then it's already be owned by the arena
- raw = if (url == .url) url_str else try arena.dupe(u8, url_str);
- }
-
- const uri = std.Uri.parse(raw.?) catch blk: {
- if (!std.mem.endsWith(u8, raw.?, "://")) {
- return error.TypeError;
- }
- // schema only is valid!
- break :blk std.Uri{
- .scheme = raw.?[0 .. raw.?.len - 3],
- .host = .{ .percent_encoded = "" },
- };
- };
-
- return init(arena, uri);
- }
-
- pub fn init(arena: Allocator, uri: std.Uri) !URL {
- return .{
- .uri = uri,
- .search_params = try URLSearchParams.init(
- arena,
- uriComponentNullStr(uri.query),
- ),
- };
- }
-
- pub fn initWithoutSearchParams(uri: std.Uri) URL {
- return .{ .uri = uri, .search_params = .{} };
- }
-
- pub fn get_origin(self: *URL, page: *Page) ![]const u8 {
- var aw = std.Io.Writer.Allocating.init(page.arena);
- try self.uri.writeToStream(&aw.writer, .{
- .scheme = true,
- .authentication = false,
- .authority = true,
- .path = false,
- .query = false,
- .fragment = false,
- });
- return aw.written();
- }
-
- // get_href returns the URL by writing all its components.
- pub fn get_href(self: *URL, page: *Page) ![]const u8 {
- return self.toString(page.arena);
- }
-
- pub fn _toString(self: *URL, page: *Page) ![]const u8 {
- return self.toString(page.arena);
- }
-
- // format the url with all its components.
- pub fn toString(self: *const URL, arena: Allocator) ![]const u8 {
- var aw = std.Io.Writer.Allocating.init(arena);
- try self.uri.writeToStream(&aw.writer, .{
- .scheme = true,
- .authentication = true,
- .authority = true,
- .path = uriComponentNullStr(self.uri.path).len > 0,
- });
-
- if (self.search_params.get_size() > 0) {
- try aw.writer.writeByte('?');
- try self.search_params.write(&aw.writer);
- }
-
- {
- const fragment = uriComponentNullStr(self.uri.fragment);
- if (fragment.len > 0) {
- try aw.writer.writeByte('#');
- try aw.writer.writeAll(fragment);
- }
- }
-
- return aw.written();
- }
-
- pub fn get_protocol(self: *const URL) []const u8 {
- // std.Uri keeps a pointer to "https", "http" (scheme part) so we know
- // its followed by ':'.
- const scheme = self.uri.scheme;
- return scheme.ptr[0 .. scheme.len + 1];
- }
-
- pub fn get_username(self: *URL) []const u8 {
- return uriComponentNullStr(self.uri.user);
- }
-
- pub fn get_password(self: *URL) []const u8 {
- return uriComponentNullStr(self.uri.password);
- }
-
- pub fn get_host(self: *URL, page: *Page) ![]const u8 {
- var aw = std.Io.Writer.Allocating.init(page.arena);
- try self.uri.writeToStream(&aw.writer, .{
- .scheme = false,
- .authentication = false,
- .authority = true,
- .path = false,
- .query = false,
- .fragment = false,
- });
- return aw.written();
- }
-
- pub fn get_hostname(self: *URL) []const u8 {
- return uriComponentNullStr(self.uri.host);
- }
-
- pub fn get_port(self: *URL, page: *Page) ![]const u8 {
- const arena = page.arena;
- if (self.uri.port == null) return try arena.dupe(u8, "");
-
- var aw = std.Io.Writer.Allocating.init(arena);
- try aw.writer.printInt(self.uri.port.?, 10, .lower, .{});
- return aw.written();
- }
-
- pub fn get_pathname(self: *URL) []const u8 {
- if (uriComponentStr(self.uri.path).len == 0) return "/";
- return uriComponentStr(self.uri.path);
- }
-
- pub fn get_search(self: *URL, page: *Page) ![]const u8 {
- const arena = page.arena;
-
- if (self.search_params.get_size() == 0) {
- return "";
- }
-
- var buf: std.ArrayListUnmanaged(u8) = .{};
- try buf.append(arena, '?');
- try self.search_params.encode(buf.writer(arena));
- return buf.items;
- }
-
- pub fn set_search(self: *URL, qs_: ?[]const u8, page: *Page) !void {
- self.search_params = .{};
- if (qs_) |qs| {
- self.search_params = try URLSearchParams.init(page.arena, qs);
- }
- }
-
- pub fn get_hash(self: *URL, page: *Page) ![]const u8 {
- const arena = page.arena;
- if (self.uri.fragment == null) return try arena.dupe(u8, "");
-
- return try std.mem.concat(arena, u8, &[_][]const u8{ "#", uriComponentNullStr(self.uri.fragment) });
- }
-
- pub fn get_searchParams(self: *URL) *URLSearchParams {
- return &self.search_params;
- }
-
- pub fn _toJSON(self: *URL, page: *Page) ![]const u8 {
- return self.get_href(page);
- }
-};
-
-// uriComponentNullStr converts an optional std.Uri.Component to string value.
-// The string value can be undecoded.
-fn uriComponentNullStr(c: ?std.Uri.Component) []const u8 {
- if (c == null) return "";
-
- return uriComponentStr(c.?);
-}
-
-fn uriComponentStr(c: std.Uri.Component) []const u8 {
- return switch (c) {
- .raw => |v| v,
- .percent_encoded => |v| v,
- };
-}
-
-// https://url.spec.whatwg.org/#interface-urlsearchparams
-pub const URLSearchParams = struct {
- entries: kv.List = .{},
-
- const URLSearchParamsOpts = union(enum) {
- qs: []const u8,
- form_data: *const FormData,
- js_obj: js.Object,
- };
- pub fn constructor(opts_: ?URLSearchParamsOpts, page: *Page) !URLSearchParams {
- const opts = opts_ orelse return .{ .entries = .{} };
- return switch (opts) {
- .qs => |qs| init(page.arena, qs),
- .form_data => |fd| .{ .entries = try fd.entries.clone(page.arena) },
- .js_obj => |js_obj| {
- const arena = page.arena;
- var it = js_obj.nameIterator();
-
- var entries: kv.List = .{};
- try entries.ensureTotalCapacity(arena, it.count);
-
- while (try it.next()) |js_name| {
- const name = try js_name.toString(arena);
- const js_val = try js_obj.get(name);
- entries.appendOwnedAssumeCapacity(
- name,
- try js_val.toString(arena),
- );
- }
-
- return .{ .entries = entries };
- },
- };
- }
-
- pub fn init(arena: Allocator, qs_: ?[]const u8) !URLSearchParams {
- return .{
- .entries = if (qs_) |qs| try parseQuery(arena, qs) else .{},
- };
- }
-
- pub fn get_size(self: *const URLSearchParams) u32 {
- return @intCast(self.entries.count());
- }
-
- pub fn _append(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void {
- return self.entries.append(page.arena, name, value);
- }
-
- pub fn _set(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void {
- return self.entries.set(page.arena, name, value);
- }
-
- pub fn _delete(self: *URLSearchParams, name: []const u8, value_: ?[]const u8) void {
- if (value_) |value| {
- return self.entries.deleteKeyValue(name, value);
- }
- return self.entries.delete(name);
- }
-
- pub fn _get(self: *const URLSearchParams, name: []const u8) ?[]const u8 {
- return self.entries.get(name);
- }
-
- pub fn _getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 {
- return self.entries.getAll(page.call_arena, name);
- }
-
- pub fn _has(self: *const URLSearchParams, name: []const u8) bool {
- return self.entries.has(name);
- }
-
- pub fn _keys(self: *const URLSearchParams) KeyIterable {
- return .{ .inner = self.entries.keyIterator() };
- }
-
- pub fn _values(self: *const URLSearchParams) ValueIterable {
- return .{ .inner = self.entries.valueIterator() };
- }
-
- pub fn _entries(self: *const URLSearchParams) EntryIterable {
- return .{ .inner = self.entries.entryIterator() };
- }
-
- pub fn _symbol_iterator(self: *const URLSearchParams) EntryIterable {
- return self._entries();
- }
-
- pub fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 {
- var arr: std.ArrayListUnmanaged(u8) = .empty;
- try self.write(arr.writer(page.call_arena));
- return arr.items;
- }
-
- fn write(self: *const URLSearchParams, writer: anytype) !void {
- return kv.urlEncode(self.entries, .query, writer);
- }
-
- // TODO
- pub fn _sort(_: *URLSearchParams) void {}
-
- fn encode(self: *const URLSearchParams, writer: anytype) !void {
- return kv.urlEncode(self.entries, .query, writer);
- }
-};
-
-// Parse the given query.
-fn parseQuery(arena: Allocator, s: []const u8) !kv.List {
- var list = kv.List{};
-
- const ln = s.len;
- if (ln == 0) {
- return list;
- }
-
- var query = if (s[0] == '?') s[1..] else s;
- while (query.len > 0) {
- const i = std.mem.indexOfScalarPos(u8, query, 0, '=') orelse query.len;
- const name = query[0..i];
-
- var value: ?[]const u8 = null;
- if (i < query.len) {
- query = query[i + 1 ..];
- const j = std.mem.indexOfScalarPos(u8, query, 0, '&') orelse query.len;
- value = query[0..j];
-
- query = if (j < query.len) query[j + 1 ..] else "";
- } else {
- query = "";
- }
-
- try list.appendOwned(
- arena,
- try unescape(arena, name),
- if (value) |v| try unescape(arena, v) else "",
- );
- }
-
- return list;
-}
-
-fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
- const HEX_CHAR = comptime blk: {
- var all = std.mem.zeroes([256]bool);
- for ('a'..('f' + 1)) |b| all[b] = true;
- for ('A'..('F' + 1)) |b| all[b] = true;
- for ('0'..('9' + 1)) |b| all[b] = true;
- break :blk all;
- };
-
- const HEX_DECODE = comptime blk: {
- var all = std.mem.zeroes([256]u8);
- for ('a'..('z' + 1)) |b| all[b] = b - 'a' + 10;
- for ('A'..('Z' + 1)) |b| all[b] = b - 'A' + 10;
- for ('0'..('9' + 1)) |b| all[b] = b - '0';
- break :blk all;
- };
-
- var has_plus = false;
- var unescaped_len = input.len;
-
- {
- // Figure out if we have any spaces and what the final unescaped length
- // will be (which will let us know if we have anything to unescape in
- // the first place)
- var i: usize = 0;
- while (i < input.len) {
- const c = input[i];
- if (c == '%') {
- if (i + 2 >= input.len or !HEX_CHAR[input[i + 1]] or !HEX_CHAR[input[i + 2]]) {
- return error.EscapeError;
- }
- i += 3;
- unescaped_len -= 2;
- } else if (c == '+') {
- has_plus = true;
- i += 1;
- } else {
- i += 1;
- }
- }
- }
-
- // no encoding, and no plus. nothing to unescape
- if (unescaped_len == input.len and has_plus == false) {
- // we always dupe, because we know our caller wants it always duped.
- return arena.dupe(u8, input);
- }
-
- var unescaped = try arena.alloc(u8, unescaped_len);
- errdefer arena.free(unescaped);
-
- var input_pos: usize = 0;
- for (0..unescaped_len) |unescaped_pos| {
- switch (input[input_pos]) {
- '+' => {
- unescaped[unescaped_pos] = ' ';
- input_pos += 1;
- },
- '%' => {
- const encoded = input[input_pos + 1 .. input_pos + 3];
- const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*));
- unescaped[unescaped_pos] = switch (encoded_as_uint) {
- asUint(u16, "20") => ' ',
- asUint(u16, "21") => '!',
- asUint(u16, "22") => '"',
- asUint(u16, "23") => '#',
- asUint(u16, "24") => '$',
- asUint(u16, "25") => '%',
- asUint(u16, "26") => '&',
- asUint(u16, "27") => '\'',
- asUint(u16, "28") => '(',
- asUint(u16, "29") => ')',
- asUint(u16, "2A") => '*',
- asUint(u16, "2B") => '+',
- asUint(u16, "2C") => ',',
- asUint(u16, "2F") => '/',
- asUint(u16, "3A") => ':',
- asUint(u16, "3B") => ';',
- asUint(u16, "3D") => '=',
- asUint(u16, "3F") => '?',
- asUint(u16, "40") => '@',
- asUint(u16, "5B") => '[',
- asUint(u16, "5D") => ']',
- else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]],
- };
- input_pos += 3;
- },
- else => |c| {
- unescaped[unescaped_pos] = c;
- input_pos += 1;
- },
- }
- }
- return unescaped;
-}
-
-fn asUint(comptime T: type, comptime string: []const u8) T {
- return @bitCast(string[0..string.len].*);
-}
-
-const KeyIterable = iterator.Iterable(kv.KeyIterator, "URLSearchParamsKeyIterator");
-const ValueIterable = iterator.Iterable(kv.ValueIterator, "URLSearchParamsValueIterator");
-const EntryIterable = iterator.Iterable(kv.EntryIterator, "URLSearchParamsEntryIterator");
-
-const testing = @import("../../testing.zig");
-test "Browser: URL" {
- try testing.htmlRunner("url/url.html");
-}
-
-test "Browser: URLSearchParams" {
- try testing.htmlRunner("url/url_search_params.html");
-}
diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig
new file mode 100644
index 000000000..13718b97f
--- /dev/null
+++ b/src/browser/webapi/AbortController.zig
@@ -0,0 +1,62 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const AbortSignal = @import("AbortSignal.zig");
+
+const AbortController = @This();
+
+_signal: *AbortSignal,
+
+pub fn init(page: *Page) !*AbortController {
+ const signal = try AbortSignal.init(page);
+ return page._factory.create(AbortController{
+ ._signal = signal,
+ });
+}
+
+pub fn getSignal(self: *const AbortController) *AbortSignal {
+ return self._signal;
+}
+
+pub fn abort(self: *AbortController, reason: ?js.Object, page: *Page) !void {
+ try self._signal.abort(reason, page);
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(AbortController);
+
+ pub const Meta = struct {
+ pub const name = "AbortController";
+
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(AbortController.init, .{});
+ pub const signal = bridge.accessor(AbortController.getSignal, null, .{});
+ pub const abort = bridge.function(AbortController.abort, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: AbortController" {
+ try testing.htmlRunner("event/abort_controller.html", .{});
+}
diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig
new file mode 100644
index 000000000..40ac9e895
--- /dev/null
+++ b/src/browser/webapi/AbortSignal.zig
@@ -0,0 +1,119 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Event = @import("Event.zig");
+const EventTarget = @import("EventTarget.zig");
+
+const AbortSignal = @This();
+
+_proto: *EventTarget,
+_aborted: bool = false,
+_reason: ?js.Object = null,
+_on_abort: ?js.Function = null,
+
+pub fn init(page: *Page) !*AbortSignal {
+ return page._factory.eventTarget(AbortSignal{
+ ._proto = undefined,
+ ._aborted = false,
+ ._reason = null,
+ ._on_abort = null,
+ });
+}
+
+pub fn getAborted(self: *const AbortSignal) bool {
+ return self._aborted;
+}
+
+pub fn getReason(self: *const AbortSignal) ?js.Object {
+ return self._reason;
+}
+
+pub fn getOnAbort(self: *const AbortSignal) ?js.Function {
+ return self._on_abort;
+}
+
+pub fn setOnAbort(self: *AbortSignal, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_abort = try cb.withThis(self);
+ } else {
+ self._on_abort = null;
+ }
+}
+
+pub fn asEventTarget(self: *AbortSignal) *EventTarget {
+ return self._proto;
+}
+
+pub fn abort(self: *AbortSignal, reason_: ?js.Object, page: *Page) !void {
+ if (self._aborted) return;
+
+ self._aborted = true;
+
+ // Store the abort reason (default to a simple string if none provided)
+ if (reason_) |reason| {
+ self._reason = try reason.persist();
+ }
+
+ // Dispatch abort event
+ const event = try Event.init("abort", .{}, page);
+ try page._event_manager.dispatchWithFunction(
+ self.asEventTarget(),
+ event,
+ self._on_abort,
+ .{ .context = "abort signal" },
+ );
+}
+
+// Static method to create an already-aborted signal
+pub fn createAborted(reason_: ?js.Object, page: *Page) !*AbortSignal {
+ const signal = try init(page);
+ try signal.abort(reason_, page);
+ return signal;
+}
+
+pub fn throwIfAborted(self: *const AbortSignal) !void {
+ if (self._aborted) {
+ return error.Aborted;
+ }
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(AbortSignal);
+
+ pub const Meta = struct {
+ pub const name = "AbortSignal";
+
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const Prototype = EventTarget;
+
+ pub const constructor = bridge.constructor(AbortSignal.init, .{});
+ pub const aborted = bridge.accessor(AbortSignal.getAborted, null, .{});
+ pub const reason = bridge.accessor(AbortSignal.getReason, null, .{});
+ pub const onabort = bridge.accessor(AbortSignal.getOnAbort, AbortSignal.setOnAbort, .{});
+ pub const throwIfAborted = bridge.function(AbortSignal.throwIfAborted, .{});
+
+ // Static method
+ pub const abort = bridge.function(AbortSignal.createAborted, .{ .static = true });
+};
diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig
new file mode 100644
index 000000000..58106caf3
--- /dev/null
+++ b/src/browser/webapi/Blob.zig
@@ -0,0 +1,322 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const Writer = std.Io.Writer;
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+/// https://w3c.github.io/FileAPI/#blob-section
+/// https://developer.mozilla.org/en-US/docs/Web/API/Blob
+const Blob = @This();
+
+const _prototype_root = true;
+
+_type: Type,
+
+/// Immutable slice of blob.
+/// Note that another blob may hold a pointer/slice to this,
+/// so its better to leave the deallocation of it to arena allocator.
+_slice: []const u8,
+/// MIME attached to blob. Can be an empty string.
+_mime: []const u8,
+
+pub const Type = union(enum) {
+ generic,
+ file: *@import("File.zig"),
+};
+
+const InitOptions = struct {
+ /// MIME type.
+ type: []const u8 = "",
+ /// How to handle line endings (CR and LF).
+ /// `transparent` means do nothing, `native` expects CRLF (\r\n) on Windows.
+ endings: []const u8 = "transparent",
+};
+
+/// Creates a new Blob.
+pub fn init(
+ maybe_blob_parts: ?[]const []const u8,
+ maybe_options: ?InitOptions,
+ page: *Page,
+) !*Blob {
+ const options: InitOptions = maybe_options orelse .{};
+ // Setup MIME; This can be any string according to my observations.
+ const mime: []const u8 = blk: {
+ const t = options.type;
+ if (t.len == 0) {
+ break :blk "";
+ }
+
+ break :blk try page.arena.dupe(u8, t);
+ };
+
+ const data = blk: {
+ if (maybe_blob_parts) |blob_parts| {
+ var w: Writer.Allocating = .init(page.arena);
+ const use_native_endings = std.mem.eql(u8, options.endings, "native");
+ try writeBlobParts(&w.writer, blob_parts, use_native_endings);
+
+ break :blk w.written();
+ }
+
+ break :blk "";
+ };
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ ._slice = data,
+ ._mime = mime,
+ });
+}
+
+const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8);
+/// Array of possible vector sizes for the current arch in decrementing order.
+/// We may move this to some file for SIMD helpers in the future.
+const vector_sizes = blk: {
+ // Required for length calculation.
+ var n: usize = largest_vector;
+ var total: usize = 0;
+ while (n != 2) : (n /= 2) total += 1;
+ // Populate an array with vector sizes.
+ n = largest_vector;
+ var i: usize = 0;
+ var items: [total]usize = undefined;
+ while (n != 2) : (n /= 2) {
+ defer i += 1;
+ items[i] = n;
+ }
+
+ break :blk items;
+};
+
+/// Writes blob parts to given `Writer` with desired endings.
+fn writeBlobParts(
+ writer: *Writer,
+ blob_parts: []const []const u8,
+ use_native_endings: bool,
+) !void {
+ // Transparent.
+ if (!use_native_endings) {
+ for (blob_parts) |part| {
+ try writer.writeAll(part);
+ }
+
+ return;
+ }
+
+ // TODO: Windows support.
+
+ // Linux & Unix.
+ // Both Firefox and Chrome implement it as such:
+ // CRLF => LF
+ // CR => LF
+ // So even though CR is not followed by LF, it gets replaced.
+ //
+ // I believe this is because such scenario is possible:
+ // ```
+ // let parts = [ "the quick\r", "\nbrown fox" ];
+ // ```
+ // In the example, one should have to check the part before in order to
+ // understand that CRLF is being presented in the final buffer.
+ // So they took a simpler approach, here's what given blob parts produce:
+ // ```
+ // "the quick\n\nbrown fox"
+ // ```
+ scan_parts: for (blob_parts) |part| {
+ var end: usize = 0;
+
+ inline for (vector_sizes) |vector_len| {
+ const Vec = @Vector(vector_len, u8);
+
+ while (end + vector_len <= part.len) : (end += vector_len) {
+ const cr: Vec = @splat('\r');
+ // Load chunk as vectors.
+ const data = part[end..][0..vector_len];
+ const chunk: Vec = data.*;
+ // Look for CR.
+ const match = chunk == cr;
+
+ // Create a bitset out of match vector.
+ const bitset = std.bit_set.IntegerBitSet(vector_len){
+ .mask = @bitCast(@intFromBool(match)),
+ };
+
+ var iter = bitset.iterator(.{});
+ var relative_start: usize = 0;
+ while (iter.next()) |index| {
+ _ = try writer.writeVec(&.{ data[relative_start..index], "\n" });
+
+ if (index + 1 != data.len and data[index + 1] == '\n') {
+ relative_start = index + 2;
+ } else {
+ relative_start = index + 1;
+ }
+ }
+
+ _ = try writer.writeVec(&.{data[relative_start..]});
+ }
+ }
+
+ // Scalar scan fallback.
+ var relative_start: usize = end;
+ while (end < part.len) {
+ if (part[end] == '\r') {
+ _ = try writer.writeVec(&.{ part[relative_start..end], "\n" });
+
+ // Part ends with CR. We can continue to next part.
+ if (end + 1 == part.len) {
+ continue :scan_parts;
+ }
+
+ // If next char is LF, skip it too.
+ if (part[end + 1] == '\n') {
+ relative_start = end + 2;
+ } else {
+ relative_start = end + 1;
+ }
+ }
+
+ end += 1;
+ }
+
+ // Write the remaining. We get this in such situations:
+ // `the quick brown\rfox`
+ // `the quick brown\r\nfox`
+ try writer.writeAll(part[relative_start..end]);
+ }
+}
+
+/// Returns a Promise that resolves with the contents of the blob
+/// as binary data contained in an ArrayBuffer.
+pub fn arrayBuffer(self: *const Blob, page: *Page) !js.Promise {
+ return page.js.resolvePromise(js.ArrayBuffer{ .values = self._slice });
+}
+
+const ReadableStream = @import("streams/ReadableStream.zig");
+/// Returns a ReadableStream which upon reading returns the data
+/// contained within the Blob.
+pub fn stream(self: *const Blob, page: *Page) !*ReadableStream {
+ return ReadableStream.initWithData(self._slice, page);
+}
+
+/// Returns a Promise that resolves with a string containing
+/// the contents of the blob, interpreted as UTF-8.
+pub fn text(self: *const Blob, page: *Page) !js.Promise {
+ return page.js.resolvePromise(self._slice);
+}
+
+/// Extension to Blob; works on Firefox and Safari.
+/// https://developer.mozilla.org/en-US/docs/Web/API/Blob/bytes
+/// Returns a Promise that resolves with a Uint8Array containing
+/// the contents of the blob as an array of bytes.
+pub fn bytes(self: *const Blob, page: *Page) !js.Promise {
+ return page.js.resolvePromise(js.TypedArray(u8){ .values = self._slice });
+}
+
+/// Returns a new Blob object which contains data
+/// from a subset of the blob on which it's called.
+pub fn slice(
+ self: *const Blob,
+ maybe_start: ?i32,
+ maybe_end: ?i32,
+ maybe_content_type: ?[]const u8,
+ page: *Page,
+) !*Blob {
+ const mime: []const u8 = blk: {
+ if (maybe_content_type) |content_type| {
+ if (content_type.len == 0) {
+ break :blk "";
+ }
+
+ break :blk try page.dupeString(content_type);
+ }
+
+ break :blk "";
+ };
+
+ const data = self._slice;
+ if (maybe_start) |_start| {
+ const start = blk: {
+ if (_start < 0) {
+ break :blk data.len -| @abs(_start);
+ }
+
+ break :blk @min(data.len, @as(u31, @intCast(_start)));
+ };
+
+ const end: usize = blk: {
+ if (maybe_end) |_end| {
+ if (_end < 0) {
+ break :blk @max(start, data.len -| @abs(_end));
+ }
+
+ break :blk @min(data.len, @max(start, @as(u31, @intCast(_end))));
+ }
+
+ break :blk data.len;
+ };
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ ._slice = data[start..end],
+ ._mime = mime,
+ });
+ }
+
+ return page._factory.create(Blob{
+ ._type = .generic,
+ ._slice = data,
+ ._mime = mime,
+ });
+}
+
+/// Returns the size of the Blob in bytes.
+pub fn getSize(self: *const Blob) usize {
+ return self._slice.len;
+}
+
+/// Returns the type of Blob; likely a MIME type, yet anything can be given.
+pub fn getType(self: *const Blob) []const u8 {
+ return self._mime;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Blob);
+
+ pub const Meta = struct {
+ pub const name = "Blob";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Blob.init, .{});
+ pub const text = bridge.function(Blob.text, .{});
+ pub const bytes = bridge.function(Blob.bytes, .{});
+ pub const slice = bridge.function(Blob.slice, .{});
+ pub const size = bridge.accessor(Blob.getSize, null, .{});
+ pub const @"type" = bridge.accessor(Blob.getType, null, .{});
+ pub const stream = bridge.function(Blob.stream, .{});
+ pub const arrayBuffer = bridge.function(Blob.arrayBuffer, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Blob" {
+ try testing.htmlRunner("blob.html", .{});
+}
diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig
new file mode 100644
index 000000000..82afee542
--- /dev/null
+++ b/src/browser/webapi/CData.zig
@@ -0,0 +1,335 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const Node = @import("Node.zig");
+pub const Text = @import("cdata/Text.zig");
+pub const Comment = @import("cdata/Comment.zig");
+pub const CDATASection = @import("cdata/CDATASection.zig");
+
+const CData = @This();
+
+_type: Type,
+_proto: *Node,
+_data: []const u8 = "",
+
+pub const Type = union(enum) {
+ text: Text,
+ comment: Comment,
+ // This should be under Text, but that would require storing a _type union
+ // in text, which would add 8 bytes to every text node.
+ cdata_section: CDATASection,
+};
+
+pub fn asNode(self: *CData) *Node {
+ return self._proto;
+}
+
+pub fn is(self: *CData, comptime T: type) ?*T {
+ inline for (@typeInfo(Type).@"union".fields) |f| {
+ if (f.type == T and @field(Type, f.name) == self._type) {
+ return &@field(self._type, f.name);
+ }
+ }
+ return null;
+}
+
+pub fn className(self: *const CData) []const u8 {
+ return switch (self._type) {
+ .text => "[object Text]",
+ .comment => "[object Comment]",
+ .cdata_section => "[object CDATASection]",
+ };
+}
+
+pub fn getData(self: *const CData) []const u8 {
+ return self._data;
+}
+
+pub const RenderOpts = struct {
+ trim_left: bool = true,
+ trim_right: bool = true,
+};
+// Replace successives whitespaces with one withespace.
+// Trims left and right according to the options.
+// Returns true if the string ends with a trimmed whitespace.
+pub fn render(self: *const CData, writer: *std.io.Writer, opts: RenderOpts) !bool {
+ var start: usize = 0;
+ var prev_w: ?bool = null;
+ var is_w: bool = undefined;
+ const s = self._data;
+
+ for (s, 0..) |c, i| {
+ is_w = std.ascii.isWhitespace(c);
+
+ // Detect the first char type.
+ if (prev_w == null) {
+ prev_w = is_w;
+ }
+ // The current char is the same kind of char, the chunk continues.
+ if (prev_w.? == is_w) {
+ continue;
+ }
+
+ // Starting here, the chunk changed.
+ if (is_w) {
+ // We have a chunk of non-whitespaces, we write it as it.
+ try writer.writeAll(s[start..i]);
+ } else {
+ // We have a chunk of whitespaces, replace with one space,
+ // depending the position.
+ if (start > 0 or !opts.trim_left) {
+ try writer.writeByte(' ');
+ }
+ }
+ // Start the new chunk.
+ prev_w = is_w;
+ start = i;
+ }
+ // Write the reminder chunk.
+ if (is_w) {
+ // Last chunk is whitespaces.
+ // If the string contains only whitespaces, don't write it.
+ if (start > 0 and opts.trim_right == false) {
+ try writer.writeByte(' ');
+ } else {
+ return true;
+ }
+ } else {
+ // last chunk is non whitespaces.
+ try writer.writeAll(s[start..]);
+ }
+
+ return false;
+}
+
+pub fn setData(self: *CData, value: ?[]const u8, page: *Page) !void {
+ const old_value = self._data;
+
+ if (value) |v| {
+ self._data = try page.dupeString(v);
+ } else {
+ self._data = "";
+ }
+
+ page.characterDataChange(self.asNode(), old_value);
+}
+
+pub fn format(self: *const CData, writer: *std.io.Writer) !void {
+ return switch (self._type) {
+ .text => writer.print("{s}", .{self._data}),
+ .comment => writer.print("", .{self._data}),
+ .cdata_section => writer.print("", .{self._data}),
+ };
+}
+
+pub fn getLength(self: *const CData) usize {
+ return self._data.len;
+}
+
+pub fn isEqualNode(self: *const CData, other: *const CData) bool {
+ return std.mem.eql(u8, self.getData(), other.getData());
+}
+
+pub fn appendData(self: *CData, data: []const u8, page: *Page) !void {
+ const new_data = try std.mem.concat(page.arena, u8, &.{ self._data, data });
+ try self.setData(new_data, page);
+}
+
+pub fn deleteData(self: *CData, offset: usize, count: usize, page: *Page) !void {
+ if (offset > self._data.len) return error.IndexSizeError;
+ const end = @min(offset + count, self._data.len);
+
+ // Just slice - original data stays in arena
+ const old_value = self._data;
+ if (offset == 0) {
+ self._data = self._data[end..];
+ } else if (end >= self._data.len) {
+ self._data = self._data[0..offset];
+ } else {
+ self._data = try std.mem.concat(page.arena, u8, &.{
+ self._data[0..offset],
+ self._data[end..],
+ });
+ }
+ page.characterDataChange(self.asNode(), old_value);
+}
+
+pub fn insertData(self: *CData, offset: usize, data: []const u8, page: *Page) !void {
+ if (offset > self._data.len) return error.IndexSizeError;
+ const new_data = try std.mem.concat(page.arena, u8, &.{
+ self._data[0..offset],
+ data,
+ self._data[offset..],
+ });
+ try self.setData(new_data, page);
+}
+
+pub fn replaceData(self: *CData, offset: usize, count: usize, data: []const u8, page: *Page) !void {
+ if (offset > self._data.len) return error.IndexSizeError;
+ const end = @min(offset + count, self._data.len);
+ const new_data = try std.mem.concat(page.arena, u8, &.{
+ self._data[0..offset],
+ data,
+ self._data[end..],
+ });
+ try self.setData(new_data, page);
+}
+
+pub fn substringData(self: *const CData, offset: usize, count: usize) ![]const u8 {
+ if (offset > self._data.len) return error.IndexSizeError;
+ const end = @min(offset + count, self._data.len);
+ return self._data[offset..end];
+}
+
+pub fn remove(self: *CData, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+ _ = try parent.removeChild(node, page);
+}
+
+pub fn before(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.insertBefore(child, node, page);
+ }
+}
+
+pub fn after(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+ const next = node.nextSibling();
+
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.insertBefore(child, next, page);
+ }
+}
+
+pub fn replaceWith(self: *CData, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+ const next = node.nextSibling();
+
+ _ = try parent.removeChild(node, page);
+
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.insertBefore(child, next, page);
+ }
+}
+
+pub fn nextElementSibling(self: *CData) ?*Node.Element {
+ var maybe_sibling = self.asNode().nextSibling();
+ while (maybe_sibling) |sibling| {
+ if (sibling.is(Node.Element)) |el| return el;
+ maybe_sibling = sibling.nextSibling();
+ }
+ return null;
+}
+
+pub fn previousElementSibling(self: *CData) ?*Node.Element {
+ var maybe_sibling = self.asNode().previousSibling();
+ while (maybe_sibling) |sibling| {
+ if (sibling.is(Node.Element)) |el| return el;
+ maybe_sibling = sibling.previousSibling();
+ }
+ return null;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CData);
+
+ pub const Meta = struct {
+ pub const name = "CharacterData";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const data = bridge.accessor(CData.getData, CData.setData, .{});
+ pub const length = bridge.accessor(CData.getLength, null, .{});
+
+ pub const appendData = bridge.function(CData.appendData, .{});
+ pub const deleteData = bridge.function(CData.deleteData, .{ .dom_exception = true });
+ pub const insertData = bridge.function(CData.insertData, .{ .dom_exception = true });
+ pub const replaceData = bridge.function(CData.replaceData, .{ .dom_exception = true });
+ pub const substringData = bridge.function(CData.substringData, .{ .dom_exception = true });
+
+ pub const remove = bridge.function(CData.remove, .{});
+ pub const before = bridge.function(CData.before, .{});
+ pub const after = bridge.function(CData.after, .{});
+ pub const replaceWith = bridge.function(CData.replaceWith, .{});
+
+ pub const nextElementSibling = bridge.accessor(CData.nextElementSibling, null, .{});
+ pub const previousElementSibling = bridge.accessor(CData.previousElementSibling, null, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: CData" {
+ try testing.htmlRunner("cdata", .{});
+}
+
+test "WebApi: CData.render" {
+ const allocator = std.testing.allocator;
+
+ const TestCase = struct {
+ value: []const u8,
+ expected: []const u8,
+ result: bool = false,
+ opts: RenderOpts = .{},
+ };
+
+ const test_cases = [_]TestCase{
+ .{ .value = " ", .expected = "", .result = true },
+ .{ .value = " ", .expected = "", .opts = .{ .trim_left = false, .trim_right = false }, .result = true },
+ .{ .value = "foo bar", .expected = "foo bar" },
+ .{ .value = "foo bar", .expected = "foo bar" },
+ .{ .value = " foo bar", .expected = "foo bar" },
+ .{ .value = "foo bar ", .expected = "foo bar", .result = true },
+ .{ .value = " foo bar ", .expected = "foo bar", .result = true },
+ .{ .value = "foo\n\tbar", .expected = "foo bar" },
+ .{ .value = "\tfoo bar baz \t\n yeah\r\n", .expected = "foo bar baz yeah", .result = true },
+ .{ .value = " foo bar", .expected = " foo bar", .opts = .{ .trim_left = false } },
+ .{ .value = "foo bar ", .expected = "foo bar ", .opts = .{ .trim_right = false } },
+ .{ .value = " foo bar ", .expected = " foo bar ", .opts = .{ .trim_left = false, .trim_right = false } },
+ };
+
+ var buffer = std.io.Writer.Allocating.init(allocator);
+ defer buffer.deinit();
+ for (test_cases) |test_case| {
+ buffer.clearRetainingCapacity();
+
+ const cdata = CData{
+ ._type = .{ .text = undefined },
+ ._proto = undefined,
+ ._data = test_case.value,
+ };
+
+ const result = try cdata.render(&buffer.writer, test_case.opts);
+
+ try std.testing.expectEqualStrings(test_case.expected, buffer.written());
+ try std.testing.expect(result == test_case.result);
+ }
+}
diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig
new file mode 100644
index 000000000..a2f320f7d
--- /dev/null
+++ b/src/browser/webapi/CSS.zig
@@ -0,0 +1,170 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const CSS = @This();
+_pad: bool = false,
+
+pub const init: CSS = .{};
+
+pub fn parseDimension(value: []const u8) ?f64 {
+ if (value.len == 0) {
+ return null;
+ }
+
+ var num_str = value;
+ if (std.mem.endsWith(u8, value, "px")) {
+ num_str = value[0 .. value.len - 2];
+ }
+
+ return std.fmt.parseFloat(f64, num_str) catch null;
+}
+
+/// Escapes a CSS identifier string
+/// https://drafts.csswg.org/cssom/#the-css.escape()-method
+pub fn escape(_: *const CSS, value: []const u8, page: *Page) ![]const u8 {
+ if (value.len == 0) {
+ return error.InvalidCharacterError;
+ }
+
+ const first = value[0];
+
+ // Count how many characters we need for the output
+ var out_len: usize = escapeLen(true, first);
+ for (value[1..]) |c| {
+ out_len += escapeLen(false, c);
+ }
+
+ if (out_len == value.len) {
+ return value;
+ }
+
+ const result = try page.call_arena.alloc(u8, out_len);
+ var pos: usize = 0;
+
+ if (needsEscape(true, first)) {
+ pos = writeEscape(true, result, first);
+ } else {
+ result[0] = first;
+ pos = 1;
+ }
+
+ for (value[1..]) |c| {
+ if (!needsEscape(false, c)) {
+ result[pos] = c;
+ pos += 1;
+ } else {
+ pos += writeEscape(false, result[pos..], c);
+ }
+ }
+
+ return result;
+}
+
+pub fn supports(_: *const CSS, property_or_condition: []const u8, value: ?[]const u8) bool {
+ _ = property_or_condition;
+ _ = value;
+ return true;
+}
+
+fn escapeLen(comptime is_first: bool, c: u8) usize {
+ if (needsEscape(is_first, c) == false) {
+ return 1;
+ }
+ if (c == 0) {
+ return "\u{FFFD}".len;
+ }
+ if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) {
+ // Will be escaped as \XX (backslash + 1-6 hex digits + space)
+ return 2 + hexDigitsNeeded(c);
+ }
+ // Escaped as \C (backslash + character)
+ return 2;
+}
+
+fn needsEscape(comptime is_first: bool, c: u8) bool {
+ if (comptime is_first) {
+ if (c >= '0' and c <= '9') {
+ return true;
+ }
+ if (c == '-') {
+ return true;
+ }
+ }
+
+ // Characters that need escaping
+ return switch (c) {
+ 0...0x1F, 0x7F => true,
+ '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '.', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '^', '`', '{', '|', '}', '~' => true,
+ ' ' => true,
+ else => false,
+ };
+}
+
+fn isHexEscape(c: u8) bool {
+ return (c >= 0x00 and c <= 0x1F) or c == 0x7F;
+}
+
+fn hexDigitsNeeded(c: u8) usize {
+ if (c < 0x10) {
+ return 1;
+ }
+ return 2;
+}
+
+fn writeEscape(comptime is_first: bool, buf: []u8, c: u8) usize {
+ buf[0] = '\\';
+ var data = buf[1..];
+
+ if (c == 0) {
+ // NULL character becomes replacement character
+ const replacement = "\u{FFFD}";
+ @memcpy(data[0..replacement.len], replacement);
+ return 1 + replacement.len;
+ }
+
+ if (isHexEscape(c) or ((comptime is_first) and c >= '0' and c <= '9')) {
+ const hex_str = std.fmt.bufPrint(data, "{x} ", .{c}) catch unreachable;
+ return 1 + hex_str.len;
+ }
+
+ data[0] = c;
+ return 2;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSS);
+
+ pub const Meta = struct {
+ pub const name = "Css";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const escape = bridge.function(CSS.escape, .{});
+ pub const supports = bridge.function(CSS.supports, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: CSS" {
+ try testing.htmlRunner("css.html", .{});
+}
diff --git a/src/browser/webapi/Console.zig b/src/browser/webapi/Console.zig
new file mode 100644
index 000000000..81fdc54b5
--- /dev/null
+++ b/src/browser/webapi/Console.zig
@@ -0,0 +1,78 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const logger = @import("../../log.zig");
+
+const Console = @This();
+_pad: bool = false,
+
+pub const init: Console = .{};
+
+pub fn log(_: *const Console, values: []js.Object, page: *Page) void {
+ logger.info(.js, "console.log", .{ValueWriter{ .page = page, .values = values }});
+}
+
+pub fn warn(_: *const Console, values: []js.Object, page: *Page) void {
+ logger.warn(.js, "console.warn", .{ValueWriter{ .page = page, .values = values }});
+}
+
+pub fn @"error"(_: *const Console, values: []js.Object, page: *Page) void {
+ logger.warn(.js, "console.error", .{ValueWriter{ .page = page, .values = values, .include_stack = true }});
+}
+
+const ValueWriter = struct {
+ page: *Page,
+ values: []js.Object,
+ include_stack: bool = false,
+
+ pub fn format(self: ValueWriter, writer: *std.io.Writer) !void {
+ for (self.values, 1..) |value, i| {
+ try writer.print("\n arg({d}): {f}", .{ i, value });
+ }
+ if (self.include_stack) {
+ try writer.print("\n stack: {s}", .{self.page.js.stackTrace() catch |err| @errorName(err) orelse "???"});
+ }
+ }
+ pub fn jsonStringify(self: ValueWriter, writer: *std.json.Stringify) !void {
+ try writer.beginArray();
+ for (self.values) |value| {
+ try writer.write(value);
+ }
+ return writer.endArray();
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Console);
+
+ pub const Meta = struct {
+ pub const name = "Console";
+
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const log = bridge.function(Console.log, .{});
+ pub const warn = bridge.function(Console.warn, .{});
+ pub const @"error" = bridge.function(Console.@"error", .{});
+};
diff --git a/src/browser/crypto/crypto.zig b/src/browser/webapi/Crypto.zig
similarity index 59%
rename from src/browser/crypto/crypto.zig
rename to src/browser/webapi/Crypto.zig
index 7cb38f31d..e8f987b55 100644
--- a/src/browser/crypto/crypto.zig
+++ b/src/browser/webapi/Crypto.zig
@@ -1,4 +1,4 @@
-// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
//
// Francis Bouvier
// Pierre Tachoire
@@ -18,28 +18,29 @@
const std = @import("std");
const js = @import("../js/js.zig");
-const uuidv4 = @import("../../id.zig").uuidv4;
-// https://w3c.github.io/webcrypto/#crypto-interface
-pub const Crypto = struct {
- _not_empty: bool = true,
+const Crypto = @This();
+_pad: bool = false,
- pub fn _getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object {
- var into = try js_obj.toZig(Crypto, "getRandomValues", RandomValues);
- const buf = into.asBuffer();
- if (buf.len > 65_536) {
- return error.QuotaExceededError;
- }
- std.crypto.random.bytes(buf);
- return js_obj;
- }
+pub const init: Crypto = .{};
- pub fn _randomUUID(_: *const Crypto) [36]u8 {
- var hex: [36]u8 = undefined;
- uuidv4(&hex);
- return hex;
+// We take a js.Value, because we want to return the same instance, not a new
+// TypedArray
+pub fn getRandomValues(_: *const Crypto, js_obj: js.Object) !js.Object {
+ var into = try js_obj.toZig(RandomValues);
+ const buf = into.asBuffer();
+ if (buf.len > 65_536) {
+ return error.QuotaExceededError;
}
-};
+ std.crypto.random.bytes(buf);
+ return js_obj;
+}
+
+pub fn randomUUID(_: *const Crypto) ![36]u8 {
+ var hex: [36]u8 = undefined;
+ @import("../../id.zig").uuidv4(&hex);
+ return hex;
+}
const RandomValues = union(enum) {
int8: []i8,
@@ -65,7 +66,21 @@ const RandomValues = union(enum) {
}
};
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Crypto);
+
+ pub const Meta = struct {
+ pub const name = "Crypto";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const getRandomValues = bridge.function(Crypto.getRandomValues, .{});
+ pub const randomUUID = bridge.function(Crypto.randomUUID, .{});
+};
+
const testing = @import("../../testing.zig");
-test "Browser: Crypto" {
- try testing.htmlRunner("crypto.html");
+test "WebApi: Crypto" {
+ try testing.htmlRunner("crypto.html", .{});
}
diff --git a/src/browser/dom/resize_observer.zig b/src/browser/webapi/CustomElementDefinition.zig
similarity index 52%
rename from src/browser/dom/resize_observer.zig
rename to src/browser/webapi/CustomElementDefinition.zig
index 448825ab6..458a33197 100644
--- a/src/browser/dom/resize_observer.zig
+++ b/src/browser/webapi/CustomElementDefinition.zig
@@ -16,39 +16,28 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+const std = @import("std");
const js = @import("../js/js.zig");
-const parser = @import("../netsurf.zig");
+const Page = @import("../Page.zig");
+const Element = @import("Element.zig");
-pub const Interfaces = .{
- ResizeObserver,
-};
+const CustomElementDefinition = @This();
-// WEB IDL https://drafts.csswg.org/resize-observer/#resize-observer-interface
-pub const ResizeObserver = struct {
- pub fn constructor(cbk: js.Function) ResizeObserver {
- _ = cbk;
- return .{};
- }
+name: []const u8,
+constructor: js.Function,
+observed_attributes: std.StringHashMapUnmanaged(void) = .{},
+// For customized built-in elements, this is the element tag they extend (e.g., .button)
+// For autonomous custom elements, this is null
+extends: ?Element.Tag = null,
- pub fn _observe(self: *const ResizeObserver, element: *parser.Element, options_: ?Options) void {
- _ = self;
- _ = element;
- _ = options_;
- return;
- }
+pub fn isAttributeObserved(self: *const CustomElementDefinition, name: []const u8) bool {
+ return self.observed_attributes.contains(name);
+}
- pub fn _unobserve(self: *const ResizeObserver, element: *parser.Element) void {
- _ = self;
- _ = element;
- return;
- }
+pub fn isAutonomous(self: *const CustomElementDefinition) bool {
+ return self.extends == null;
+}
- // TODO
- pub fn _disconnect(self: *ResizeObserver) void {
- _ = self;
- }
-};
-
-const Options = struct {
- box: []const u8,
-};
+pub fn isCustomizedBuiltIn(self: *const CustomElementDefinition) bool {
+ return self.extends != null;
+}
diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig
new file mode 100644
index 000000000..b0646c9aa
--- /dev/null
+++ b/src/browser/webapi/CustomElementRegistry.zig
@@ -0,0 +1,255 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const log = @import("../../log.zig");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+const Custom = @import("element/html/Custom.zig");
+const CustomElementDefinition = @import("CustomElementDefinition.zig");
+
+const CustomElementRegistry = @This();
+
+_definitions: std.StringHashMapUnmanaged(*CustomElementDefinition) = .{},
+_when_defined: std.StringHashMapUnmanaged(js.PersistentPromiseResolver) = .{},
+
+const DefineOptions = struct {
+ extends: ?[]const u8 = null,
+};
+
+pub fn define(self: *CustomElementRegistry, name: []const u8, constructor: js.Function, options_: ?DefineOptions, page: *Page) !void {
+ const options = options_ orelse DefineOptions{};
+
+ try validateName(name);
+
+ // Parse and validate extends option
+ const extends_tag: ?Element.Tag = if (options.extends) |extends_name| blk: {
+ const tag = std.meta.stringToEnum(Element.Tag, extends_name) orelse return error.NotSupported;
+
+ // Can't extend custom elements
+ if (tag == .custom) {
+ return error.NotSupported;
+ }
+
+ break :blk tag;
+ } else null;
+
+ const gop = try self._definitions.getOrPut(page.arena, name);
+ if (gop.found_existing) {
+ // Yes, this is the correct error to return when trying to redefine a name
+ return error.NotSupported;
+ }
+
+ const owned_name = try page.dupeString(name);
+
+ const definition = try page._factory.create(CustomElementDefinition{
+ .name = owned_name,
+ .constructor = constructor,
+ .extends = extends_tag,
+ });
+
+ // Read observedAttributes static property from constructor
+ if (constructor.getPropertyValue("observedAttributes") catch null) |observed_attrs| {
+ if (observed_attrs.isArray()) {
+ var js_arr = observed_attrs.toArray();
+ for (0..js_arr.len()) |i| {
+ const attr_val = js_arr.get(i) catch continue;
+ const attr_name = attr_val.toString(page.arena) catch continue;
+ const owned_attr = page.dupeString(attr_name) catch continue;
+ definition.observed_attributes.put(page.arena, owned_attr, {}) catch continue;
+ }
+ }
+ }
+
+ gop.key_ptr.* = owned_name;
+ gop.value_ptr.* = definition;
+
+ // Upgrade any undefined custom elements with this name
+ var idx: usize = 0;
+ while (idx < page._undefined_custom_elements.items.len) {
+ const custom = page._undefined_custom_elements.items[idx];
+ if (!custom._tag_name.eqlSlice(name)) {
+ idx += 1;
+ continue;
+ }
+
+ if (!custom.asElement().asNode().isConnected()) {
+ idx += 1;
+ continue;
+ }
+
+ upgradeCustomElement(custom, definition, page) catch {
+ _ = page._undefined_custom_elements.swapRemove(idx);
+ continue;
+ };
+
+ _ = page._undefined_custom_elements.swapRemove(idx);
+ }
+
+ if (self._when_defined.fetchRemove(name)) |entry| {
+ entry.value.resolve("whenDefined", constructor);
+ }
+}
+
+pub fn get(self: *CustomElementRegistry, name: []const u8) ?js.Function {
+ const definition = self._definitions.get(name) orelse return null;
+ return definition.constructor;
+}
+
+pub fn upgrade(self: *CustomElementRegistry, root: *Node, page: *Page) !void {
+ try upgradeNode(self, root, page);
+}
+
+pub fn whenDefined(self: *CustomElementRegistry, name: []const u8, page: *Page) !js.Promise {
+ if (self._definitions.get(name)) |definition| {
+ return page.js.resolvePromise(definition.constructor);
+ }
+
+ const gop = try self._when_defined.getOrPut(page.arena, name);
+ if (gop.found_existing) {
+ return gop.value_ptr.promise();
+ }
+ errdefer _ = self._when_defined.remove(name);
+ const owned_name = try page.dupeString(name);
+
+ const resolver = try page.js.createPromiseResolver(.page);
+ gop.key_ptr.* = owned_name;
+ gop.value_ptr.* = resolver;
+
+ return resolver.promise();
+}
+
+fn upgradeNode(self: *CustomElementRegistry, node: *Node, page: *Page) !void {
+ if (node.is(Element)) |element| {
+ try upgradeElement(self, element, page);
+ }
+
+ var it = node.childrenIterator();
+ while (it.next()) |child| {
+ try upgradeNode(self, child, page);
+ }
+}
+
+fn upgradeElement(self: *CustomElementRegistry, element: *Element, page: *Page) !void {
+ const custom = element.is(Custom) orelse {
+ return Custom.checkAndAttachBuiltIn(element, page);
+ };
+
+ if (custom._definition != null) return;
+
+ const name = custom._tag_name.str();
+ const definition = self._definitions.get(name) orelse return;
+
+ try upgradeCustomElement(custom, definition, page);
+}
+
+pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinition, page: *Page) !void {
+ custom._definition = definition;
+
+ // Reset callback flags since this is a fresh upgrade
+ custom._connected_callback_invoked = false;
+ custom._disconnected_callback_invoked = false;
+
+ const node = custom.asNode();
+ const prev_upgrading = page._upgrading_element;
+ page._upgrading_element = node;
+ defer page._upgrading_element = prev_upgrading;
+
+ var result: js.Function.Result = undefined;
+ _ = definition.constructor.newInstance(&result) catch |err| {
+ log.warn(.js, "custom element upgrade", .{ .name = definition.name, .err = err });
+ return error.CustomElementUpgradeFailed;
+ };
+
+ // Invoke attributeChangedCallback for existing observed attributes
+ var attr_it = custom.asElement().attributeIterator();
+ while (attr_it.next()) |attr| {
+ const name = attr._name.str();
+ if (definition.isAttributeObserved(name)) {
+ custom.invokeAttributeChangedCallback(name, null, attr._value.str(), page);
+ }
+ }
+
+ if (node.isConnected()) {
+ custom.invokeConnectedCallback(page);
+ }
+}
+
+fn validateName(name: []const u8) !void {
+ if (name.len == 0) {
+ return error.InvalidCustomElementName;
+ }
+
+ if (std.mem.indexOf(u8, name, "-") == null) {
+ return error.InvalidCustomElementName;
+ }
+
+ if (name[0] < 'a' or name[0] > 'z') {
+ return error.InvalidCustomElementName;
+ }
+
+ const reserved_names = [_][]const u8{
+ "annotation-xml",
+ "color-profile",
+ "font-face",
+ "font-face-src",
+ "font-face-uri",
+ "font-face-format",
+ "font-face-name",
+ "missing-glyph",
+ };
+
+ for (reserved_names) |reserved| {
+ if (std.mem.eql(u8, name, reserved)) {
+ return error.InvalidCustomElementName;
+ }
+ }
+
+ for (name) |c| {
+ const valid = (c >= 'a' and c <= 'z') or
+ (c >= '0' and c <= '9') or
+ c == '-';
+ if (!valid) {
+ return error.InvalidCustomElementName;
+ }
+ }
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CustomElementRegistry);
+
+ pub const Meta = struct {
+ pub const name = "CustomElementRegistry";
+ pub var class_id: bridge.ClassId = undefined;
+ pub const prototype_chain = bridge.prototypeChain();
+ };
+
+ pub const define = bridge.function(CustomElementRegistry.define, .{ .dom_exception = true });
+ pub const get = bridge.function(CustomElementRegistry.get, .{ .null_as_undefined = true });
+ pub const upgrade = bridge.function(CustomElementRegistry.upgrade, .{});
+ pub const whenDefined = bridge.function(CustomElementRegistry.whenDefined, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: CustomElementRegistry" {
+ try testing.htmlRunner("custom_elements", .{});
+}
diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig
new file mode 100644
index 000000000..7ae241d2d
--- /dev/null
+++ b/src/browser/webapi/DOMException.zig
@@ -0,0 +1,93 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const DOMException = @This();
+_code: Code = .none,
+
+pub fn init() DOMException {
+ return .{};
+}
+
+pub fn fromError(err: anyerror) ?DOMException {
+ return switch (err) {
+ error.SyntaxError => .{ ._code = .syntax_error },
+ error.InvalidCharacterError => .{ ._code = .invalid_character_error },
+ error.NotFound => .{ ._code = .not_found },
+ error.NotSupported => .{ ._code = .not_supported },
+ error.HierarchyError => .{ ._code = .hierarchy_error },
+ error.IndexSizeError => .{ ._code = .index_size_error },
+ else => null,
+ };
+}
+
+pub fn getCode(self: *const DOMException) u8 {
+ return @intFromEnum(self._code);
+}
+
+pub fn getName(self: *const DOMException) []const u8 {
+ return switch (self._code) {
+ .none => "Error",
+ .invalid_character_error => "InvalidCharacterError",
+ .index_size_error => "IndexSizeErorr",
+ .syntax_error => "SyntaxError",
+ .not_found => "NotFoundError",
+ .not_supported => "NotSupportedError",
+ .hierarchy_error => "HierarchyError",
+ };
+}
+
+pub fn getMessage(self: *const DOMException) []const u8 {
+ return switch (self._code) {
+ .none => "",
+ .invalid_character_error => "Error: Invalid Character",
+ .index_size_error => "IndexSizeError: Index or size is negative or greater than the allowed amount",
+ .syntax_error => "Syntax Error",
+ .not_supported => "Not Supported",
+ .not_found => "Not Found",
+ .hierarchy_error => "Hierarchy Error",
+ };
+}
+
+const Code = enum(u8) {
+ none = 0,
+ index_size_error = 1,
+ hierarchy_error = 3,
+ invalid_character_error = 5,
+ not_found = 8,
+ not_supported = 9,
+ syntax_error = 12,
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMException);
+
+ pub const Meta = struct {
+ pub const name = "DOMException";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(DOMException.init, .{});
+ pub const code = bridge.accessor(DOMException.getCode, null, .{});
+ pub const name = bridge.accessor(DOMException.getName, null, .{});
+ pub const message = bridge.accessor(DOMException.getMessage, null, .{});
+ pub const toString = bridge.function(DOMException.getMessage, .{});
+};
diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig
new file mode 100644
index 000000000..e2a863571
--- /dev/null
+++ b/src/browser/webapi/DOMImplementation.zig
@@ -0,0 +1,75 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const DocumentType = @import("DocumentType.zig");
+
+const DOMImplementation = @This();
+
+pub fn createDocumentType(_: *const DOMImplementation, qualified_name: []const u8, public_id: ?[]const u8, system_id: ?[]const u8, page: *Page) !*DocumentType {
+ const name = try page.dupeString(qualified_name);
+ const pub_id = try page.dupeString(public_id orelse "");
+ const sys_id = try page.dupeString(system_id orelse "");
+
+ const doctype = try page._factory.node(DocumentType{
+ ._proto = undefined,
+ ._name = name,
+ ._public_id = pub_id,
+ ._system_id = sys_id,
+ });
+
+ return doctype;
+}
+
+pub fn hasFeature(_: *const DOMImplementation, _: []const u8, _: ?[]const u8) bool {
+ // Modern DOM spec says this should always return true
+ // This method is deprecated and kept for compatibility only
+ return true;
+}
+
+pub fn className(_: *const DOMImplementation) []const u8 {
+ return "[object DOMImplementation]";
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMImplementation);
+
+ pub const Meta = struct {
+ pub const name = "DOMImplementation";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{ .dom_exception = true });
+ 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");
+test "WebApi: DOMImplementation" {
+ try testing.htmlRunner("domimplementation.html", .{});
+}
diff --git a/src/browser/webapi/DOMNodeIterator.zig b/src/browser/webapi/DOMNodeIterator.zig
new file mode 100644
index 000000000..3314416e5
--- /dev/null
+++ b/src/browser/webapi/DOMNodeIterator.zig
@@ -0,0 +1,187 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const Node = @import("Node.zig");
+const NodeFilter = @import("NodeFilter.zig");
+const TreeWalker = @import("TreeWalker.zig");
+pub const FilterOpts = NodeFilter.FilterOpts;
+
+const DOMNodeIterator = @This();
+
+_root: *Node,
+_what_to_show: u32,
+_filter: NodeFilter,
+_reference_node: *Node,
+_pointer_before_reference_node: bool,
+
+pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, page: *Page) !*DOMNodeIterator {
+ const node_filter = try NodeFilter.init(filter);
+ return page._factory.create(DOMNodeIterator{
+ ._root = root,
+ ._filter = node_filter,
+ ._reference_node = root,
+ ._what_to_show = what_to_show,
+ ._pointer_before_reference_node = true,
+ });
+}
+
+pub fn getRoot(self: *const DOMNodeIterator) *Node {
+ return self._root;
+}
+
+pub fn getReferenceNode(self: *const DOMNodeIterator) *Node {
+ return self._reference_node;
+}
+
+pub fn getPointerBeforeReferenceNode(self: *const DOMNodeIterator) bool {
+ return self._pointer_before_reference_node;
+}
+
+pub fn getWhatToShow(self: *const DOMNodeIterator) u32 {
+ return self._what_to_show;
+}
+
+pub fn getFilter(self: *const DOMNodeIterator) ?FilterOpts {
+ return self._filter._original_filter;
+}
+
+pub fn nextNode(self: *DOMNodeIterator) !?*Node {
+ var node = self._reference_node;
+ var before_node = self._pointer_before_reference_node;
+
+ while (true) {
+ if (before_node) {
+ before_node = false;
+ const result = try self.filterNode(node);
+ if (result == NodeFilter.FILTER_ACCEPT) {
+ self._reference_node = node;
+ self._pointer_before_reference_node = false;
+ return node;
+ }
+ } else {
+ // Move to next node in tree order
+ const next = self.getNextInTree(node);
+ if (next == null) {
+ return null;
+ }
+ node = next.?;
+
+ const result = try self.filterNode(node);
+ if (result == NodeFilter.FILTER_ACCEPT) {
+ self._reference_node = node;
+ self._pointer_before_reference_node = false;
+ return node;
+ }
+ }
+ }
+}
+
+pub fn previousNode(self: *DOMNodeIterator) !?*Node {
+ var node = self._reference_node;
+ var before_node = self._pointer_before_reference_node;
+
+ while (true) {
+ if (!before_node) {
+ const result = try self.filterNode(node);
+ if (result == NodeFilter.FILTER_ACCEPT) {
+ self._reference_node = node;
+ self._pointer_before_reference_node = true;
+ return node;
+ }
+ before_node = true;
+ }
+
+ // Move to previous node in tree order
+ const prev = self.getPreviousInTree(node);
+ if (prev == null) {
+ return null;
+ }
+ node = prev.?;
+ before_node = false;
+ }
+}
+
+fn filterNode(self: *const DOMNodeIterator, node: *Node) !i32 {
+ // First check whatToShow
+ if (!NodeFilter.shouldShow(node, self._what_to_show)) {
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ // Then check the filter callback
+ // For NodeIterator, REJECT and SKIP are equivalent - both skip the node
+ // but continue with its descendants
+ const result = try self._filter.acceptNode(node);
+ return result;
+}
+
+fn getNextInTree(self: *const DOMNodeIterator, node: *Node) ?*Node {
+ // Depth-first traversal within the root subtree
+ if (node._children) |children| {
+ return children.first();
+ }
+
+ var current = node;
+ while (current != self._root) {
+ if (current.nextSibling()) |sibling| {
+ return sibling;
+ }
+ current = current._parent orelse return null;
+ }
+
+ return null;
+}
+
+fn getPreviousInTree(self: *const DOMNodeIterator, node: *Node) ?*Node {
+ if (node == self._root) {
+ return null;
+ }
+
+ if (node.previousSibling()) |sibling| {
+ // Go to the last descendant of the sibling
+ var last = sibling;
+ while (last.lastChild()) |child| {
+ last = child;
+ }
+ return last;
+ }
+
+ return node._parent;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMNodeIterator);
+
+ pub const Meta = struct {
+ pub const name = "NodeIterator";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const root = bridge.accessor(DOMNodeIterator.getRoot, null, .{});
+ pub const referenceNode = bridge.accessor(DOMNodeIterator.getReferenceNode, null, .{});
+ pub const pointerBeforeReferenceNode = bridge.accessor(DOMNodeIterator.getPointerBeforeReferenceNode, null, .{});
+ pub const whatToShow = bridge.accessor(DOMNodeIterator.getWhatToShow, null, .{});
+ pub const filter = bridge.accessor(DOMNodeIterator.getFilter, null, .{});
+
+ pub const nextNode = bridge.function(DOMNodeIterator.nextNode, .{});
+ pub const previousNode = bridge.function(DOMNodeIterator.previousNode, .{});
+};
diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig
new file mode 100644
index 000000000..051f004b3
--- /dev/null
+++ b/src/browser/webapi/DOMParser.zig
@@ -0,0 +1,74 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Document = @import("Document.zig");
+const HTMLDocument = @import("HTMLDocument.zig");
+
+const DOMParser = @This();
+
+pub fn init() DOMParser {
+ return .{};
+}
+
+pub fn parseFromString(self: *const DOMParser, html: []const u8, mime_type: []const u8, page: *Page) !*HTMLDocument {
+ _ = self;
+
+ // For now, only support text/html
+ if (!std.mem.eql(u8, mime_type, "text/html")) {
+ return error.NotSupported;
+ }
+
+ // Create a new HTMLDocument
+ const doc = try page._factory.document(HTMLDocument{
+ ._proto = undefined,
+ });
+
+ // Parse HTML into the document
+ const Parser = @import("../parser/Parser.zig");
+ var parser = Parser.init(page.arena, doc.asNode(), page);
+ parser.parse(html);
+
+ if (parser.err) |pe| {
+ return pe.err;
+ }
+
+ return doc;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMParser);
+
+ pub const Meta = struct {
+ pub const name = "DOMParser";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const constructor = bridge.constructor(DOMParser.init, .{});
+ pub const parseFromString = bridge.function(DOMParser.parseFromString, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: DOMParser" {
+ try testing.htmlRunner("domparser.html", .{});
+}
diff --git a/src/browser/webapi/DOMRect.zig b/src/browser/webapi/DOMRect.zig
new file mode 100644
index 000000000..4b3e36723
--- /dev/null
+++ b/src/browser/webapi/DOMRect.zig
@@ -0,0 +1,82 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const DOMRect = @This();
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+_x: f64,
+_y: f64,
+_width: f64,
+_height: f64,
+_top: f64,
+_right: f64,
+_bottom: f64,
+_left: f64,
+
+pub fn getX(self: *DOMRect) f64 {
+ return self._x;
+}
+
+pub fn getY(self: *DOMRect) f64 {
+ return self._y;
+}
+
+pub fn getWidth(self: *DOMRect) f64 {
+ return self._width;
+}
+
+pub fn getHeight(self: *DOMRect) f64 {
+ return self._height;
+}
+
+pub fn getTop(self: *DOMRect) f64 {
+ return self._top;
+}
+
+pub fn getRight(self: *DOMRect) f64 {
+ return self._right;
+}
+
+pub fn getBottom(self: *DOMRect) f64 {
+ return self._bottom;
+}
+
+pub fn getLeft(self: *DOMRect) f64 {
+ return self._left;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMRect);
+
+ pub const Meta = struct {
+ pub const name = "DOMRect";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const x = bridge.accessor(DOMRect.getX, null, .{});
+ pub const y = bridge.accessor(DOMRect.getY, null, .{});
+ pub const width = bridge.accessor(DOMRect.getWidth, null, .{});
+ pub const height = bridge.accessor(DOMRect.getHeight, null, .{});
+ pub const top = bridge.accessor(DOMRect.getTop, null, .{});
+ pub const right = bridge.accessor(DOMRect.getRight, null, .{});
+ pub const bottom = bridge.accessor(DOMRect.getBottom, null, .{});
+ pub const left = bridge.accessor(DOMRect.getLeft, null, .{});
+};
diff --git a/src/browser/webapi/DOMTreeWalker.zig b/src/browser/webapi/DOMTreeWalker.zig
new file mode 100644
index 000000000..88ca271a1
--- /dev/null
+++ b/src/browser/webapi/DOMTreeWalker.zig
@@ -0,0 +1,281 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const Node = @import("Node.zig");
+const NodeFilter = @import("NodeFilter.zig");
+pub const FilterOpts = NodeFilter.FilterOpts;
+
+const DOMTreeWalker = @This();
+
+_root: *Node,
+_what_to_show: u32,
+_filter: NodeFilter,
+_current: *Node,
+
+pub fn init(root: *Node, what_to_show: u32, filter: ?FilterOpts, page: *Page) !*DOMTreeWalker {
+ const node_filter = try NodeFilter.init(filter);
+ return page._factory.create(DOMTreeWalker{
+ ._root = root,
+ ._current = root,
+ ._filter = node_filter,
+ ._what_to_show = what_to_show,
+ });
+}
+
+pub fn getRoot(self: *const DOMTreeWalker) *Node {
+ return self._root;
+}
+
+pub fn getWhatToShow(self: *const DOMTreeWalker) u32 {
+ return self._what_to_show;
+}
+
+pub fn getFilter(self: *const DOMTreeWalker) ?FilterOpts {
+ return self._filter._original_filter;
+}
+
+pub fn getCurrentNode(self: *const DOMTreeWalker) *Node {
+ return self._current;
+}
+
+pub fn setCurrentNode(self: *DOMTreeWalker, node: *Node) void {
+ self._current = node;
+}
+
+// Navigation methods
+pub fn parentNode(self: *DOMTreeWalker) !?*Node {
+ var node = self._current._parent;
+ while (node) |n| {
+ if (n == self._root._parent) {
+ return null;
+ }
+ if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
+ self._current = n;
+ return n;
+ }
+ node = n._parent;
+ }
+ return null;
+}
+
+pub fn firstChild(self: *DOMTreeWalker) !?*Node {
+ var node = self._current.firstChild();
+ while (node) |n| {
+ if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
+ self._current = n;
+ return n;
+ }
+ node = self.nextSiblingOrNull(n);
+ }
+ return null;
+}
+
+pub fn lastChild(self: *DOMTreeWalker) !?*Node {
+ var node = self._current.lastChild();
+ while (node) |n| {
+ if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
+ self._current = n;
+ return n;
+ }
+ node = self.previousSiblingOrNull(n);
+ }
+ return null;
+}
+
+pub fn previousSibling(self: *DOMTreeWalker) !?*Node {
+ var node = self.previousSiblingOrNull(self._current);
+ while (node) |n| {
+ if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
+ self._current = n;
+ return n;
+ }
+ node = self.previousSiblingOrNull(n);
+ }
+ return null;
+}
+
+pub fn nextSibling(self: *DOMTreeWalker) !?*Node {
+ var node = self.nextSiblingOrNull(self._current);
+ while (node) |n| {
+ if (try self.acceptNode(n) == NodeFilter.FILTER_ACCEPT) {
+ self._current = n;
+ return n;
+ }
+ node = self.nextSiblingOrNull(n);
+ }
+ return null;
+}
+
+pub fn previousNode(self: *DOMTreeWalker) !?*Node {
+ var node = self._current;
+ while (node != self._root) {
+ 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;
+ }
+ }
+ if (try self.acceptNode(node) == NodeFilter.FILTER_ACCEPT) {
+ self._current = node;
+ return node;
+ }
+ sibling = self.previousSiblingOrNull(node);
+ }
+
+ if (node == self._root) {
+ return null;
+ }
+
+ const parent = node._parent orelse return null;
+ if (try self.acceptNode(parent) == NodeFilter.FILTER_ACCEPT) {
+ self._current = parent;
+ return parent;
+ }
+ node = parent;
+ }
+ return null;
+}
+
+pub fn nextNode(self: *DOMTreeWalker) !?*Node {
+ var node = self._current;
+
+ while (true) {
+ // Try children first (depth-first)
+ if (node.firstChild()) |child| {
+ node = child;
+ const filter_result = try self.acceptNode(node);
+ if (filter_result == NodeFilter.FILTER_ACCEPT) {
+ self._current = node;
+ return node;
+ }
+ // If REJECT, skip this entire subtree; if SKIP, try children
+ if (filter_result == NodeFilter.FILTER_REJECT) {
+ // Skip this node and its children - continue with siblings
+ // Don't update node, will try siblings below
+ } else {
+ // SKIP - already moved to child, will try its children on next iteration
+ continue;
+ }
+ }
+
+ // No (more) children, try siblings
+ while (true) {
+ if (node == self._root) {
+ return null;
+ }
+
+ if (node.nextSibling()) |sibling| {
+ node = sibling;
+ const filter_result = try self.acceptNode(node);
+ if (filter_result == NodeFilter.FILTER_ACCEPT) {
+ self._current = node;
+ return node;
+ }
+ // If REJECT, skip subtree; if SKIP, try children
+ if (filter_result == NodeFilter.FILTER_REJECT) {
+ // Continue sibling loop to get next sibling
+ continue;
+ } else {
+ // SKIP - try this node's children
+ break;
+ }
+ }
+
+ // No sibling, go up to parent
+ node = node._parent orelse return null;
+ }
+ }
+}
+
+// Helper methods
+fn acceptNode(self: *const DOMTreeWalker, node: *Node) !i32 {
+ // First check whatToShow
+ if (!NodeFilter.shouldShow(node, self._what_to_show)) {
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ // Then check the filter callback
+ // For TreeWalker, REJECT means reject node and its descendants
+ // SKIP means skip node but check its descendants
+ // ACCEPT means accept the node
+ return try self._filter.acceptNode(node);
+}
+
+fn isInSubtree(self: *const DOMTreeWalker, node: *Node) bool {
+ var current = node;
+ while (current._parent) |parent| {
+ if (parent == self._root) {
+ return true;
+ }
+ current = parent;
+ }
+ return current == self._root;
+}
+
+fn firstChildOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node {
+ _ = self;
+ return node.firstChild();
+}
+
+fn lastChildOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node {
+ _ = self;
+ return node.lastChild();
+}
+
+fn nextSiblingOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node {
+ _ = self;
+ return node.nextSibling();
+}
+
+fn previousSiblingOrNull(self: *const DOMTreeWalker, node: *Node) ?*Node {
+ _ = self;
+ return node.previousSibling();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMTreeWalker);
+
+ pub const Meta = struct {
+ pub const name = "TreeWalker";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const root = bridge.accessor(DOMTreeWalker.getRoot, null, .{});
+ pub const whatToShow = bridge.accessor(DOMTreeWalker.getWhatToShow, null, .{});
+ pub const filter = bridge.accessor(DOMTreeWalker.getFilter, null, .{});
+ pub const currentNode = bridge.accessor(DOMTreeWalker.getCurrentNode, DOMTreeWalker.setCurrentNode, .{});
+
+ pub const parentNode = bridge.function(DOMTreeWalker.parentNode, .{});
+ pub const firstChild = bridge.function(DOMTreeWalker.firstChild, .{});
+ pub const lastChild = bridge.function(DOMTreeWalker.lastChild, .{});
+ pub const previousSibling = bridge.function(DOMTreeWalker.previousSibling, .{});
+ pub const nextSibling = bridge.function(DOMTreeWalker.nextSibling, .{});
+ pub const previousNode = bridge.function(DOMTreeWalker.previousNode, .{});
+ pub const nextNode = bridge.function(DOMTreeWalker.nextNode, .{});
+};
diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig
new file mode 100644
index 000000000..87019a549
--- /dev/null
+++ b/src/browser/webapi/Document.zig
@@ -0,0 +1,462 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const String = @import("../../string.zig").String;
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const URL = @import("../URL.zig");
+
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+const Location = @import("Location.zig");
+const collections = @import("collections.zig");
+const Selector = @import("selector/Selector.zig");
+const NodeFilter = @import("NodeFilter.zig");
+const DOMTreeWalker = @import("DOMTreeWalker.zig");
+const DOMNodeIterator = @import("DOMNodeIterator.zig");
+const DOMImplementation = @import("DOMImplementation.zig");
+const StyleSheetList = @import("css/StyleSheetList.zig");
+
+pub const HTMLDocument = @import("HTMLDocument.zig");
+
+const Document = @This();
+
+_type: Type,
+_proto: *Node,
+_location: ?*Location = null,
+_ready_state: ReadyState = .loading,
+_current_script: ?*Element.Html.Script = null,
+_elements_by_id: std.StringHashMapUnmanaged(*Element) = .empty,
+_active_element: ?*Element = null,
+_style_sheets: ?*StyleSheetList = null,
+
+pub const Type = union(enum) {
+ generic,
+ html: *HTMLDocument,
+};
+
+pub fn is(self: *Document, comptime T: type) ?*T {
+ switch (self._type) {
+ .html => |html| {
+ if (T == HTMLDocument) {
+ return html;
+ }
+ },
+ .generic => {},
+ }
+ return null;
+}
+
+pub fn as(self: *Document, comptime T: type) *T {
+ return self.is(T).?;
+}
+
+pub fn asNode(self: *Document) *Node {
+ return self._proto;
+}
+
+pub fn asEventTarget(self: *Document) *@import("EventTarget.zig") {
+ return self._proto.asEventTarget();
+}
+
+pub fn getURL(_: *const Document, page: *const Page) [:0]const u8 {
+ return page.url;
+}
+
+pub fn getContentType(self: *const Document) []const u8 {
+ return switch (self._type) {
+ .html => "text/html",
+ .generic => "application/xml",
+ };
+}
+
+pub fn getCharacterSet(_: *const Document) []const u8 {
+ return "UTF-8";
+}
+
+pub fn getCompatMode(_: *const Document) []const u8 {
+ return "CSS1Compat";
+}
+
+pub fn getReferrer(_: *const Document) []const u8 {
+ return "";
+}
+
+pub fn getDomain(_: *const Document, page: *const Page) []const u8 {
+ return URL.getHostname(page.url);
+}
+
+const CreateElementOptions = struct {
+ is: ?[]const u8 = null,
+};
+
+pub fn createElement(_: *const Document, name: []const u8, options_: ?CreateElementOptions, page: *Page) !*Element {
+ const node = try page.createElement(null, name, null);
+ const element = node.as(Element);
+
+ const options = options_ orelse return element;
+ if (options.is) |is_value| {
+ try element.setAttribute("is", is_value, page);
+ try Element.Html.Custom.checkAndAttachBuiltIn(element, page);
+ }
+
+ return element;
+}
+
+pub fn createElementNS(_: *const Document, namespace: ?[]const u8, name: []const u8, page: *Page) !*Element {
+ const node = try page.createElement(namespace, name, null);
+ return node.as(Element);
+}
+
+pub fn createAttribute(_: *const Document, name: []const u8, page: *Page) !?*Element.Attribute {
+ try Element.Attribute.validateAttributeName(name);
+ return page._factory.node(Element.Attribute{
+ ._proto = undefined,
+ ._name = try page.dupeString(name),
+ ._value = "",
+ ._element = null,
+ });
+}
+
+pub fn getElementById(self: *const Document, id_: ?[]const u8) ?*Element {
+ const id = id_ orelse return null;
+ return self._elements_by_id.get(id);
+}
+
+const GetElementsByTagNameResult = union(enum) {
+ tag: collections.NodeLive(.tag),
+ tag_name: collections.NodeLive(.tag_name),
+ all_elements: collections.NodeLive(.all_elements),
+};
+pub fn getElementsByTagName(self: *Document, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult {
+ if (tag_name.len > 256) {
+ // 256 seems generous.
+ return error.InvalidTagName;
+ }
+
+ // Handle wildcard '*' - return all elements
+ if (std.mem.eql(u8, tag_name, "*")) {
+ return .{
+ .all_elements = collections.NodeLive(.all_elements).init(self.asNode(), {}, page),
+ };
+ }
+
+ const lower = std.ascii.lowerString(&page.buf, tag_name);
+ if (Node.Element.Tag.parseForMatch(lower)) |known| {
+ // optimized for known tag names, comparis
+ return .{
+ .tag = collections.NodeLive(.tag).init(self.asNode(), known, page),
+ };
+ }
+
+ const arena = page.arena;
+ const filter = try String.init(arena, lower, .{});
+ return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) };
+}
+
+pub fn getElementsByClassName(self: *Document, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) {
+ const arena = page.arena;
+
+ // Parse space-separated class names
+ 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, try page.dupeString(name));
+ }
+
+ return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page);
+}
+
+pub fn getElementsByName(self: *Document, name: []const u8, page: *Page) !collections.NodeLive(.name) {
+ const arena = page.arena;
+ const filter = try arena.dupe(u8, name);
+ return collections.NodeLive(.name).init(self.asNode(), filter, page);
+}
+
+pub fn getChildren(self: *Document, page: *Page) !collections.NodeLive(.child_elements) {
+ return collections.NodeLive(.child_elements).init(self.asNode(), {}, page);
+}
+
+pub fn getDocumentElement(self: *Document) ?*Element {
+ var child = self.asNode().firstChild();
+ while (child) |node| {
+ if (node.is(Element)) |el| {
+ return el;
+ }
+ child = node.nextSibling();
+ }
+ return null;
+}
+
+pub fn querySelector(self: *Document, input: []const u8, page: *Page) !?*Element {
+ return Selector.querySelector(self.asNode(), input, page);
+}
+
+pub fn querySelectorAll(self: *Document, input: []const u8, page: *Page) !*Selector.List {
+ return Selector.querySelectorAll(self.asNode(), input, page);
+}
+
+pub fn className(self: *const Document) []const u8 {
+ return switch (self._type) {
+ .generic => "[object Document]",
+ .html => "[object HTMLDocument]",
+ };
+}
+
+pub fn getImplementation(_: *const Document) DOMImplementation {
+ return .{};
+}
+
+pub fn createDocumentFragment(_: *const Document, page: *Page) !*@import("DocumentFragment.zig") {
+ return @import("DocumentFragment.zig").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,
+ .generic => return page.createCDATASection(data),
+ }
+}
+
+const Range = @import("Range.zig");
+pub fn createRange(_: *const Document, page: *Page) !*Range {
+ return Range.init(page);
+}
+
+pub fn createEvent(_: *const Document, event_type: []const u8, page: *Page) !*@import("Event.zig") {
+ const Event = @import("Event.zig");
+
+ if (std.ascii.eqlIgnoreCase(event_type, "event") or std.ascii.eqlIgnoreCase(event_type, "events") or std.ascii.eqlIgnoreCase(event_type, "htmlevents")) {
+ return Event.init("", null, page);
+ }
+
+ if (std.ascii.eqlIgnoreCase(event_type, "customevent") or std.ascii.eqlIgnoreCase(event_type, "customevents")) {
+ const CustomEvent = @import("event/CustomEvent.zig");
+ const custom_event = try CustomEvent.init("", null, page);
+ return custom_event.asEvent();
+ }
+
+ if (std.ascii.eqlIgnoreCase(event_type, "messageevent")) {
+ return error.NotSupported;
+ }
+
+ 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 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 getReadyState(self: *const Document) []const u8 {
+ return @tagName(self._ready_state);
+}
+
+pub fn getActiveElement(self: *Document) ?*Element {
+ if (self._active_element) |el| {
+ return el;
+ }
+
+ // Default to body if it exists
+ if (self.is(HTMLDocument)) |html_doc| {
+ if (html_doc.getBody()) |body| {
+ return body.asElement();
+ }
+ }
+
+ // Fallback to document element
+ return self.getDocumentElement();
+}
+
+pub fn getStyleSheets(self: *Document, page: *Page) !*StyleSheetList {
+ if (self._style_sheets) |sheets| {
+ return sheets;
+ }
+ const sheets = try StyleSheetList.init(page);
+ self._style_sheets = sheets;
+ return sheets;
+}
+
+pub fn adoptNode(_: *const Document, node: *Node, page: *Page) !*Node {
+ if (node._type == .document) {
+ return error.NotSupported;
+ }
+
+ if (node._parent) |parent| {
+ page.removeNode(parent, node, .{ .will_be_reconnected = false });
+ }
+
+ return node;
+}
+
+pub fn importNode(_: *const Document, node: *Node, deep_: ?bool, page: *Page) !*Node {
+ if (node._type == .document) {
+ return error.NotSupported;
+ }
+
+ return node.cloneNode(deep_, page);
+}
+
+pub fn append(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.appendChild(child, page);
+ }
+}
+
+pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ var i = nodes.len;
+ while (i > 0) {
+ i -= 1;
+ const child = try nodes[i].toNode(page);
+ _ = try parent.insertBefore(child, parent.firstChild(), page);
+ }
+}
+
+pub fn elementFromPoint(self: *Document, x: f64, y: f64, page: *Page) !?*Element {
+ // Traverse document in depth-first order to find the topmost (last in document order)
+ // element that contains the point (x, y)
+ var topmost: ?*Element = null;
+
+ const root = self.asNode();
+ var stack: std.ArrayList(*Node) = .empty;
+ try stack.append(page.call_arena, root);
+
+ while (stack.items.len > 0) {
+ const node = stack.pop() orelse break;
+ if (node.is(Element)) |element| {
+ if (try element.checkVisibility(page)) {
+ const rect = try element.getBoundingClientRect(page);
+ if (x >= rect._left and x <= rect._right and y >= rect._top and y <= rect._bottom) {
+ topmost = element;
+ }
+ }
+ }
+
+ // Add children to stack in reverse order so we process them in document order
+ var child = node.lastChild();
+ while (child) |c| {
+ try stack.append(page.call_arena, c);
+ child = c.previousSibling();
+ }
+ }
+
+ return topmost;
+}
+
+pub fn elementsFromPoint(self: *Document, x: f64, y: f64, page: *Page) ![]const *Element {
+ // Get topmost element
+ var current: ?*Element = (try self.elementFromPoint(x, y, page)) orelse return &.{};
+ var result: std.ArrayList(*Element) = .empty;
+ while (current) |el| {
+ try result.append(page.call_arena, el);
+ current = el.parentElement();
+ }
+ return result.items;
+}
+
+const ReadyState = enum {
+ loading,
+ interactive,
+ complete,
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Document);
+
+ pub const Meta = struct {
+ pub const name = "Document";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(_constructor, .{});
+ fn _constructor(page: *Page) !*Document {
+ return page._factory.node(Document{
+ ._proto = undefined,
+ ._type = .generic,
+ });
+ }
+
+ pub const URL = bridge.accessor(Document.getURL, null, .{});
+ pub const documentURI = bridge.accessor(Document.getURL, null, .{});
+ pub const documentElement = bridge.accessor(Document.getDocumentElement, null, .{});
+ pub const children = bridge.accessor(Document.getChildren, null, .{});
+ pub const readyState = bridge.accessor(Document.getReadyState, null, .{});
+ pub const implementation = bridge.accessor(Document.getImplementation, null, .{});
+ pub const activeElement = bridge.accessor(Document.getActiveElement, null, .{});
+ pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{});
+ pub const contentType = bridge.accessor(Document.getContentType, null, .{});
+ pub const characterSet = bridge.accessor(Document.getCharacterSet, null, .{});
+ pub const charset = bridge.accessor(Document.getCharacterSet, null, .{});
+ pub const inputEncoding = bridge.accessor(Document.getCharacterSet, null, .{});
+ pub const compatMode = bridge.accessor(Document.getCompatMode, null, .{});
+ pub const referrer = bridge.accessor(Document.getReferrer, null, .{});
+ pub const domain = bridge.accessor(Document.getDomain, null, .{});
+ pub const createElement = bridge.function(Document.createElement, .{});
+ pub const createElementNS = bridge.function(Document.createElementNS, .{});
+ pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{});
+ pub const createComment = bridge.function(Document.createComment, .{});
+ pub const createTextNode = bridge.function(Document.createTextNode, .{});
+ pub const createAttribute = bridge.function(Document.createAttribute, .{ .dom_exception = true });
+ pub const createCDATASection = bridge.function(Document.createCDATASection, .{ .dom_exception = true });
+ pub const createRange = bridge.function(Document.createRange, .{});
+ 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 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, .{});
+ pub const getElementsByClassName = bridge.function(Document.getElementsByClassName, .{});
+ pub const getElementsByName = bridge.function(Document.getElementsByName, .{});
+ pub const adoptNode = bridge.function(Document.adoptNode, .{ .dom_exception = true });
+ pub const importNode = bridge.function(Document.importNode, .{ .dom_exception = true });
+ pub const append = bridge.function(Document.append, .{});
+ pub const prepend = bridge.function(Document.prepend, .{});
+ pub const elementFromPoint = bridge.function(Document.elementFromPoint, .{});
+ pub const elementsFromPoint = bridge.function(Document.elementsFromPoint, .{});
+
+ pub const defaultView = bridge.accessor(struct {
+ fn defaultView(_: *const Document, page: *Page) *@import("Window.zig") {
+ return page.window;
+ }
+ }.defaultView, null, .{ .cache = "defaultView" });
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Document" {
+ try testing.htmlRunner("document", .{});
+}
diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig
new file mode 100644
index 000000000..5e94f3abe
--- /dev/null
+++ b/src/browser/webapi/DocumentFragment.zig
@@ -0,0 +1,235 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+const ShadowRoot = @import("ShadowRoot.zig");
+const collections = @import("collections.zig");
+const Selector = @import("selector/Selector.zig");
+
+const DocumentFragment = @This();
+
+_type: Type,
+_proto: *Node,
+
+pub const Type = union(enum) {
+ generic,
+ shadow_root: *ShadowRoot,
+};
+
+pub fn is(self: *DocumentFragment, comptime T: type) ?*T {
+ switch (self._type) {
+ .shadow_root => |shadow_root| {
+ if (T == ShadowRoot) {
+ return shadow_root;
+ }
+ },
+ .generic => {},
+ }
+ return null;
+}
+
+pub fn as(self: *DocumentFragment, comptime T: type) *T {
+ return self.is(T).?;
+}
+
+pub fn init(page: *Page) !*DocumentFragment {
+ return page._factory.node(DocumentFragment{
+ ._type = .generic,
+ ._proto = undefined,
+ });
+}
+
+pub fn asNode(self: *DocumentFragment) *Node {
+ return self._proto;
+}
+
+pub fn asEventTarget(self: *DocumentFragment) *@import("EventTarget.zig") {
+ return self._proto.asEventTarget();
+}
+
+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;
+
+ var tw = @import("TreeWalker.zig").Full.Elements.init(self.asNode(), .{});
+ while (tw.next()) |el| {
+ if (el.getAttributeSafe("id")) |element_id| {
+ if (std.mem.eql(u8, element_id, id)) {
+ return el;
+ }
+ }
+ }
+ return null;
+}
+
+pub fn querySelector(self: *DocumentFragment, selector: []const u8, page: *Page) !?*Element {
+ return Selector.querySelector(self.asNode(), selector, page);
+}
+
+pub fn querySelectorAll(self: *DocumentFragment, input: []const u8, page: *Page) !*Selector.List {
+ return Selector.querySelectorAll(self.asNode(), input, page);
+}
+
+pub fn getChildren(self: *DocumentFragment, page: *Page) !collections.NodeLive(.child_elements) {
+ return collections.NodeLive(.child_elements).init(self.asNode(), {}, page);
+}
+
+pub fn firstElementChild(self: *DocumentFragment) ?*Element {
+ var maybe_child = self.asNode().firstChild();
+ while (maybe_child) |child| {
+ if (child.is(Element)) |el| return el;
+ maybe_child = child.nextSibling();
+ }
+ return null;
+}
+
+pub fn lastElementChild(self: *DocumentFragment) ?*Element {
+ var maybe_child = self.asNode().lastChild();
+ while (maybe_child) |child| {
+ if (child.is(Element)) |el| return el;
+ maybe_child = child.previousSibling();
+ }
+ return null;
+}
+
+pub fn getChildElementCount(self: *DocumentFragment) usize {
+ var count: usize = 0;
+ var it = self.asNode().childrenIterator();
+ while (it.next()) |node| {
+ if (node.is(Element) != null) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+pub fn append(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.appendChild(child, page);
+ }
+}
+
+pub fn prepend(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ var i = nodes.len;
+ while (i > 0) {
+ i -= 1;
+ const child = try nodes[i].toNode(page);
+ _ = try parent.insertBefore(child, parent.firstChild(), page);
+ }
+}
+
+pub fn replaceChildren(self: *DocumentFragment, nodes: []const Node.NodeOrText, page: *Page) !void {
+ page.domChanged();
+ var parent = self.asNode();
+
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ page.removeNode(parent, child, .{ .will_be_reconnected = false });
+ }
+
+ const parent_is_connected = parent.isConnected();
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ try page.appendNode(parent, child, .{ .child_already_connected = parent_is_connected });
+ }
+}
+
+pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, page: *Page) !void {
+ const dump = @import("../dump.zig");
+ return dump.children(self.asNode(), .{ .shadow = .complete }, writer, page);
+}
+
+pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, page: *Page) !void {
+ const parent = self.asNode();
+
+ page.domChanged();
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ page.removeNode(parent, child, .{ .will_be_reconnected = false });
+ }
+
+ if (html.len == 0) {
+ return;
+ }
+
+ try page.parseHtmlAsChildren(parent, html);
+}
+
+pub fn cloneFragment(self: *DocumentFragment, deep: bool, page: *Page) !*Node {
+ const fragment = try DocumentFragment.init(page);
+ const fragment_node = fragment.asNode();
+
+ if (deep) {
+ const node = self.asNode();
+ const self_is_connected = node.isConnected();
+
+ var child_it = node.childrenIterator();
+ while (child_it.next()) |child| {
+ const cloned_child = try child.cloneNode(true, page);
+ try page.appendNode(fragment_node, cloned_child, .{ .child_already_connected = self_is_connected });
+ }
+ }
+
+ return fragment_node;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DocumentFragment);
+
+ pub const Meta = struct {
+ pub const name = "DocumentFragment";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(DocumentFragment.init, .{});
+
+ pub const getElementById = bridge.function(DocumentFragment.getElementById, .{});
+ 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, .{});
+ pub const childElementCount = bridge.accessor(DocumentFragment.getChildElementCount, null, .{});
+ pub const firstElementChild = bridge.accessor(DocumentFragment.firstElementChild, null, .{});
+ pub const lastElementChild = bridge.accessor(DocumentFragment.lastElementChild, null, .{});
+ pub const append = bridge.function(DocumentFragment.append, .{});
+ pub const prepend = bridge.function(DocumentFragment.prepend, .{});
+ pub const replaceChildren = bridge.function(DocumentFragment.replaceChildren, .{});
+ pub const innerHTML = bridge.accessor(_innerHTML, DocumentFragment.setInnerHTML, .{});
+
+ fn _innerHTML(self: *DocumentFragment, page: *Page) ![]const u8 {
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.getInnerHTML(&buf.writer, page);
+ return buf.written();
+ }
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: DocumentFragment" {
+ try testing.htmlRunner("document_fragment", .{});
+}
diff --git a/src/browser/webapi/DocumentType.zig b/src/browser/webapi/DocumentType.zig
new file mode 100644
index 000000000..aab8052eb
--- /dev/null
+++ b/src/browser/webapi/DocumentType.zig
@@ -0,0 +1,78 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+
+const DocumentType = @This();
+
+_proto: *Node,
+_name: []const u8,
+_public_id: []const u8,
+_system_id: []const u8,
+
+pub fn asNode(self: *DocumentType) *Node {
+ return self._proto;
+}
+
+pub fn asEventTarget(self: *DocumentType) *@import("EventTarget.zig") {
+ return self._proto.asEventTarget();
+}
+
+pub fn getName(self: *const DocumentType) []const u8 {
+ return self._name;
+}
+
+pub fn getPublicId(self: *const DocumentType) []const u8 {
+ return self._public_id;
+}
+
+pub fn getSystemId(self: *const DocumentType) []const u8 {
+ return self._system_id;
+}
+
+pub fn className(_: *const DocumentType) []const u8 {
+ return "[object DocumentType]";
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DocumentType);
+
+ pub const Meta = struct {
+ pub const name = "DocumentType";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ 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();
+ }
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: DOMImplementation" {
+ try testing.htmlRunner("domimplementation.html", .{});
+}
diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig
new file mode 100644
index 000000000..7de5f14fd
--- /dev/null
+++ b/src/browser/webapi/Element.zig
@@ -0,0 +1,1334 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const log = @import("../../log.zig");
+const String = @import("../../string.zig").String;
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const reflect = @import("../reflect.zig");
+
+const Node = @import("Node.zig");
+const CSS = @import("CSS.zig");
+const ShadowRoot = @import("ShadowRoot.zig");
+const collections = @import("collections.zig");
+const Selector = @import("selector/Selector.zig");
+const Animation = @import("animation/Animation.zig");
+const DOMStringMap = @import("element/DOMStringMap.zig");
+const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
+
+pub const DOMRect = @import("DOMRect.zig");
+pub const Svg = @import("element/Svg.zig");
+pub const Html = @import("element/Html.zig");
+pub const Attribute = @import("element/Attribute.zig");
+
+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 ShadowRootLookup = std.AutoHashMapUnmanaged(*Element, *ShadowRoot);
+
+pub const Namespace = enum(u8) {
+ html,
+ svg,
+ mathml,
+ xml,
+
+ pub fn toUri(self: Namespace) []const u8 {
+ return switch (self) {
+ .html => "http://www.w3.org/1999/xhtml",
+ .svg => "http://www.w3.org/2000/svg",
+ .mathml => "http://www.w3.org/1998/Math/MathML",
+ .xml => "http://www.w3.org/XML/1998/namespace",
+ };
+ }
+};
+
+_type: Type,
+_proto: *Node,
+_namespace: Namespace = .html,
+_attributes: ?*Attribute.List = null,
+
+pub const Type = union(enum) {
+ html: *Html,
+ svg: *Svg,
+};
+
+pub fn is(self: *Element, comptime T: type) ?*T {
+ const type_name = @typeName(T);
+ switch (self._type) {
+ .html => |el| {
+ if (T == *Html) {
+ return el;
+ }
+ if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.html.")) {
+ return el.is(T);
+ }
+ },
+ .svg => |svg| {
+ if (T == *Svg) {
+ return svg;
+ }
+ if (comptime std.mem.startsWith(u8, type_name, "webapi.element.svg.")) {
+ return svg.is(T);
+ }
+ },
+ }
+ return null;
+}
+
+pub fn as(self: *Element, comptime T: type) *T {
+ return self.is(T).?;
+}
+
+pub fn asNode(self: *Element) *Node {
+ return self._proto;
+}
+
+pub fn asEventTarget(self: *Element) *@import("EventTarget.zig") {
+ return self._proto.asEventTarget();
+}
+
+pub fn asConstNode(self: *const Element) *const Node {
+ return self._proto;
+}
+
+pub fn className(self: *const Element) []const u8 {
+ return switch (self._type) {
+ inline else => |c| return c.className(),
+ };
+}
+
+pub fn attributesEql(self: *const Element, other: *Element) bool {
+ if (self._attributes) |attr_list| {
+ const other_list = other._attributes orelse return false;
+ return attr_list.eql(other_list);
+ }
+ // Make sure no attrs in both sides.
+ return other._attributes == null;
+}
+
+/// TODO: localName and prefix comparison.
+pub fn isEqualNode(self: *Element, other: *Element) bool {
+ const self_tag = self.getTagNameDump();
+ const other_tag = other.getTagNameDump();
+ // Compare namespaces and tags.
+ const dirty = self._namespace != other._namespace or !std.mem.eql(u8, self_tag, other_tag);
+ if (dirty) {
+ return false;
+ }
+
+ // Compare attributes.
+ if (!self.attributesEql(other)) {
+ return false;
+ }
+
+ // Compare children.
+ var self_iter = self.asNode().childrenIterator();
+ var other_iter = other.asNode().childrenIterator();
+ var self_count: usize = 0;
+ var other_count: usize = 0;
+ while (self_iter.next()) |self_node| : (self_count += 1) {
+ const other_node = other_iter.next() orelse return false;
+ other_count += 1;
+ if (self_node.isEqualNode(other_node)) {
+ continue;
+ }
+
+ return false;
+ }
+
+ // Make sure both have equal number of children.
+ return self_count == other_count;
+}
+
+pub fn getTagNameLower(self: *const Element) []const u8 {
+ switch (self._type) {
+ .html => |he| switch (he._type) {
+ .custom => |ce| {
+ @branchHint(.unlikely);
+ return ce._tag_name.str();
+ },
+ else => return switch (he._type) {
+ .anchor => "a",
+ .body => "body",
+ .br => "br",
+ .button => "button",
+ .custom => |e| e._tag_name.str(),
+ .data => "data",
+ .dialog => "dialog",
+ .div => "div",
+ .embed => "embed",
+ .form => "form",
+ .generic => |e| e._tag_name.str(),
+ .heading => |e| e._tag_name.str(),
+ .head => "head",
+ .html => "html",
+ .hr => "hr",
+ .iframe => "iframe",
+ .img => "img",
+ .input => "input",
+ .li => "li",
+ .link => "link",
+ .media => |m| switch (m._type) {
+ .audio => "audio",
+ .video => "video",
+ .generic => "media",
+ },
+ .meta => "meta",
+ .ol => "ol",
+ .option => "option",
+ .p => "p",
+ .script => "script",
+ .select => "select",
+ .slot => "slot",
+ .style => "style",
+ .template => "template",
+ .text_area => "textarea",
+ .title => "title",
+ .ul => "ul",
+ .unknown => |e| e._tag_name.str(),
+ },
+ },
+ .svg => |svg| return svg._tag_name.str(),
+ }
+}
+
+pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
+ switch (self._type) {
+ .html => |he| switch (he._type) {
+ .custom => |e| {
+ @branchHint(.unlikely);
+ return upperTagName(&e._tag_name, buf);
+ },
+ else => return switch (he._type) {
+ .anchor => "A",
+ .body => "BODY",
+ .br => "BR",
+ .button => "BUTTON",
+ .custom => |e| upperTagName(&e._tag_name, buf),
+ .data => "DATA",
+ .dialog => "DIALOG",
+ .div => "DIV",
+ .embed => "EMBED",
+ .form => "FORM",
+ .generic => |e| upperTagName(&e._tag_name, buf),
+ .heading => |e| upperTagName(&e._tag_name, buf),
+ .head => "HEAD",
+ .html => "HTML",
+ .hr => "HR",
+ .iframe => "IFRAME",
+ .img => "IMG",
+ .input => "INPUT",
+ .li => "LI",
+ .link => "LINK",
+ .meta => "META",
+ .media => |m| switch (m._type) {
+ .audio => "AUDIO",
+ .video => "VIDEO",
+ .generic => "MEDIA",
+ },
+ .ol => "OL",
+ .option => "OPTION",
+ .p => "P",
+ .script => "SCRIPT",
+ .select => "SELECT",
+ .slot => "SLOT",
+ .style => "STYLE",
+ .template => "TEMPLATE",
+ .text_area => "TEXTAREA",
+ .title => "TITLE",
+ .ul => "UL",
+ .unknown => |e| switch (self._namespace) {
+ .html => upperTagName(&e._tag_name, buf),
+ .svg, .xml, .mathml => return e._tag_name.str(),
+ },
+ },
+ },
+ .svg => |svg| return svg._tag_name.str(),
+ }
+}
+
+pub fn getTagNameDump(self: *const Element) []const u8 {
+ switch (self._type) {
+ .html => return self.getTagNameLower(),
+ .svg => |svg| return svg._tag_name.str(),
+ }
+}
+
+pub fn getNamespaceURI(self: *const Element) []const u8 {
+ return self._namespace.toUri();
+}
+
+pub fn getLocalName(self: *Element) []const u8 {
+ const name = self.getTagNameLower();
+ if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| {
+ return name[pos + 1 ..];
+ }
+
+ return name;
+}
+
+// innerText represents the **rendered** text content of a node and its
+// descendants.
+pub fn getInnerText(self: *Element, writer: *std.Io.Writer) !void {
+ var state = innerTextState{};
+ return try self._getInnerText(writer, &state);
+}
+const innerTextState = struct {
+ pre_w: bool = false,
+ trim_left: bool = true,
+};
+fn _getInnerText(self: *Element, writer: *std.Io.Writer, state: *innerTextState) !void {
+ var it = self.asNode().childrenIterator();
+ while (it.next()) |child| {
+ switch (child._type) {
+ .element => |e| switch (e._type) {
+ .html => |he| switch (he._type) {
+ .br => {
+ try writer.writeByte('\n');
+ state.pre_w = false; // prevent a next pre space.
+ state.trim_left = true;
+ },
+ .script, .style, .template => {
+ state.pre_w = false; // prevent a next pre space.
+ state.trim_left = true;
+ },
+ else => try e._getInnerText(writer, state), // TODO check if elt is hidden.
+ },
+ .svg => {},
+ },
+ .cdata => |c| switch (c._type) {
+ .comment => {
+ state.pre_w = false; // prevent a next pre space.
+ state.trim_left = true;
+ },
+ .text => {
+ if (state.pre_w) try writer.writeByte(' ');
+ state.pre_w = try c.render(writer, .{ .trim_left = state.trim_left });
+ // if we had a pre space, trim left next one.
+ state.trim_left = state.pre_w;
+ },
+ // CDATA sections should not be used within HTML. They are
+ // considered comments and are not displayed.
+ .cdata_section => {},
+ },
+ .document => {},
+ .document_type => {},
+ .document_fragment => {},
+ .attribute => |attr| try writer.writeAll(attr._value),
+ }
+ }
+}
+
+pub fn setInnerText(self: *Element, text: []const u8, page: *Page) !void {
+ const parent = self.asNode();
+
+ // Remove all existing children
+ page.domChanged();
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ page.removeNode(parent, child, .{ .will_be_reconnected = false });
+ }
+
+ // Fast path: skip if text is empty
+ if (text.len == 0) {
+ return;
+ }
+
+ // Create and append text node
+ const text_node = try page.createTextNode(text);
+ try page.appendNode(parent, text_node, .{ .child_already_connected = false });
+}
+
+pub fn getOuterHTML(self: *Element, writer: *std.Io.Writer, page: *Page) !void {
+ const dump = @import("../dump.zig");
+ return dump.deep(self.asNode(), .{ .shadow = .skip }, writer, page);
+}
+
+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);
+}
+
+pub fn setInnerHTML(self: *Element, html: []const u8, page: *Page) !void {
+ const parent = self.asNode();
+
+ // Remove all existing children
+ page.domChanged();
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ page.removeNode(parent, child, .{ .will_be_reconnected = false });
+ }
+
+ // Fast path: skip parsing if html is empty
+ if (html.len == 0) {
+ return;
+ }
+
+ // Parse and add new children
+ try page.parseHtmlAsChildren(parent, html);
+}
+
+pub fn getId(self: *const Element) []const u8 {
+ return self.getAttributeSafe("id") orelse "";
+}
+
+pub fn setId(self: *Element, value: []const u8, page: *Page) !void {
+ return self.setAttributeSafe("id", value, page);
+}
+
+pub fn getDir(self: *const Element) []const u8 {
+ return self.getAttributeSafe("dir") orelse "";
+}
+
+pub fn setDir(self: *Element, value: []const u8, page: *Page) !void {
+ return self.setAttributeSafe("dir", value, page);
+}
+
+pub fn getClassName(self: *const Element) []const u8 {
+ return self.getAttributeSafe("class") orelse "";
+}
+
+pub fn setClassName(self: *Element, value: []const u8, page: *Page) !void {
+ return self.setAttributeSafe("class", value, page);
+}
+
+pub fn attributeIterator(self: *Element) Attribute.InnerIterator {
+ const attributes = self._attributes orelse return .{};
+ return attributes.iterator();
+}
+
+pub fn getAttribute(self: *const Element, name: []const u8, page: *Page) !?[]const u8 {
+ const attributes = self._attributes orelse return null;
+ return attributes.get(name, page);
+}
+
+pub fn getAttributeSafe(self: *const Element, name: []const u8) ?[]const u8 {
+ const attributes = self._attributes orelse return null;
+ return attributes.getSafe(name);
+}
+
+pub fn hasAttribute(self: *const Element, name: []const u8, page: *Page) !bool {
+ const attributes = self._attributes orelse return false;
+ const value = try attributes.get(name, page);
+ return value != null;
+}
+
+pub fn hasAttributeSafe(self: *const Element, name: []const u8) bool {
+ const attributes = self._attributes orelse return false;
+ return attributes.hasSafe(name);
+}
+
+pub fn hasAttributes(self: *const Element) bool {
+ const attributes = self._attributes orelse return false;
+ return attributes.isEmpty() == false;
+}
+
+pub fn getAttributeNode(self: *Element, name: []const u8, page: *Page) !?*Attribute {
+ const attributes = self._attributes orelse return null;
+ return attributes.getAttribute(name, self, page);
+}
+
+pub fn setAttribute(self: *Element, name: []const u8, value: []const u8, page: *Page) !void {
+ try Attribute.validateAttributeName(name);
+ const attributes = try self.getOrCreateAttributeList(page);
+ _ = try attributes.put(name, value, self, page);
+}
+
+pub fn setAttributeSafe(self: *Element, name: []const u8, value: []const u8, page: *Page) !void {
+ const attributes = try self.getOrCreateAttributeList(page);
+ _ = try attributes.putSafe(name, value, self, page);
+}
+
+pub fn getOrCreateAttributeList(self: *Element, page: *Page) !*Attribute.List {
+ return self._attributes orelse return self.createAttributeList(page);
+}
+
+pub fn createAttributeList(self: *Element, page: *Page) !*Attribute.List {
+ std.debug.assert(self._attributes == null);
+ const a = try page.arena.create(Attribute.List);
+ a.* = .{ .normalize = self._namespace == .html };
+ self._attributes = a;
+ return a;
+}
+
+pub fn getShadowRoot(self: *Element, page: *Page) ?*ShadowRoot {
+ const shadow_root = page._element_shadow_roots.get(self) orelse return null;
+ if (shadow_root._mode == .closed) return null;
+ return shadow_root;
+}
+
+pub fn attachShadow(self: *Element, mode_str: []const u8, page: *Page) !*ShadowRoot {
+ if (page._element_shadow_roots.get(self)) |_| {
+ return error.AlreadyHasShadowRoot;
+ }
+ const mode = try ShadowRoot.Mode.fromString(mode_str);
+ const shadow_root = try ShadowRoot.init(self, mode, page);
+ try page._element_shadow_roots.put(page.arena, self, shadow_root);
+ return shadow_root;
+}
+
+pub fn insertAdjacentHTML(
+ self: *Element,
+ position: []const u8,
+ /// TODO: Add support for XML parsing.
+ html_or_xml: []const u8,
+ page: *Page,
+) !void {
+ // Create a new HTMLDocument.
+ const doc = try page._factory.document(@import("HTMLDocument.zig"){
+ ._proto = undefined,
+ });
+ const doc_node = doc.asNode();
+
+ const Parser = @import("../parser/Parser.zig");
+ var parser = Parser.init(page.call_arena, doc_node, page);
+ parser.parse(html_or_xml);
+ // Check if there's parsing error.
+ if (parser.err) |_| return error.Invalid;
+
+ // We always get it wrapped like so:
+ // { ... }
+ // None of the following can be null.
+ const maybe_html_node = doc_node.firstChild();
+ std.debug.assert(maybe_html_node != null);
+ const html_node = maybe_html_node orelse return;
+
+ const maybe_body_node = html_node.lastChild();
+ std.debug.assert(maybe_body_node != null);
+ const body = maybe_body_node orelse return;
+
+ const target_node, const prev_node = try self.asNode().findAdjacentNodes(position);
+
+ var iter = body.childrenIterator();
+ while (iter.next()) |child_node| {
+ _ = try target_node.insertBefore(child_node, prev_node, page);
+ }
+}
+
+pub fn insertAdjacentElement(
+ self: *Element,
+ position: []const u8,
+ element: *Element,
+ page: *Page,
+) !void {
+ const target_node, const prev_node = try self.asNode().findAdjacentNodes(position);
+ _ = try target_node.insertBefore(element.asNode(), prev_node, page);
+}
+
+pub fn insertAdjacentText(
+ self: *Element,
+ where: []const u8,
+ data: []const u8,
+ page: *Page,
+) !void {
+ const text_node = try page.createTextNode(data);
+ const target_node, const prev_node = try self.asNode().findAdjacentNodes(where);
+ _ = try target_node.insertBefore(text_node, prev_node, page);
+}
+
+pub fn setAttributeNode(self: *Element, attr: *Attribute, page: *Page) !?*Attribute {
+ if (attr._element) |el| {
+ if (el == self) {
+ return attr;
+ }
+ attr._element = null;
+ _ = try el.removeAttributeNode(attr, page);
+ }
+
+ const attributes = try self.getOrCreateAttributeList(page);
+ return attributes.putAttribute(attr, self, page);
+}
+
+pub fn removeAttribute(self: *Element, name: []const u8, page: *Page) !void {
+ const attributes = self._attributes orelse return;
+ return attributes.delete(name, self, page);
+}
+
+pub fn toggleAttribute(self: *Element, name: []const u8, force: ?bool, page: *Page) !bool {
+ try Attribute.validateAttributeName(name);
+ const has = try self.hasAttribute(name, page);
+
+ const should_add = force orelse !has;
+
+ if (should_add and !has) {
+ try self.setAttribute(name, "", page);
+ return true;
+ } else if (!should_add and has) {
+ try self.removeAttribute(name, page);
+ return false;
+ }
+
+ return should_add;
+}
+
+pub fn removeAttributeNode(self: *Element, attr: *Attribute, page: *Page) !*Attribute {
+ if (attr._element == null or attr._element.? != self) {
+ return error.NotFound;
+ }
+ try self.removeAttribute(attr._name, page);
+ attr._element = null;
+ return attr;
+}
+
+pub fn getAttributeNames(self: *const Element, page: *Page) ![][]const u8 {
+ const attributes = self._attributes orelse return &.{};
+ return attributes.getNames(page);
+}
+
+pub fn getAttributeNamedNodeMap(self: *Element, page: *Page) !*Attribute.NamedNodeMap {
+ const gop = try page._attribute_named_node_map_lookup.getOrPut(page.arena, @intFromPtr(self));
+ if (!gop.found_existing) {
+ const attributes = try self.getOrCreateAttributeList(page);
+ const named_node_map = try page._factory.create(Attribute.NamedNodeMap{ ._list = attributes, ._element = self });
+ gop.value_ptr.* = named_node_map;
+ }
+ return gop.value_ptr.*;
+}
+
+pub fn getStyle(self: *Element, page: *Page) !*CSSStyleProperties {
+ const gop = try page._element_styles.getOrPut(page.arena, self);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = try CSSStyleProperties.init(self, page);
+ }
+ return gop.value_ptr.*;
+}
+
+pub fn getClassList(self: *Element, page: *Page) !*collections.DOMTokenList {
+ const gop = try page._element_class_lists.getOrPut(page.arena, self);
+ if (!gop.found_existing) {
+ gop.value_ptr.* = try page._factory.create(collections.DOMTokenList{
+ ._element = self,
+ ._attribute_name = "class",
+ });
+ }
+ 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) {
+ gop.value_ptr.* = try page._factory.create(DOMStringMap{
+ ._element = self,
+ });
+ }
+ return gop.value_ptr.*;
+}
+
+pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
+ page.domChanged();
+ var parent = self.asNode();
+
+ var it = parent.childrenIterator();
+ while (it.next()) |child| {
+ page.removeNode(parent, child, .{ .will_be_reconnected = false });
+ }
+
+ const parent_is_connected = parent.isConnected();
+ for (nodes) |node_or_text| {
+ var child_connected = false;
+ const child = try node_or_text.toNode(page);
+ if (child._parent) |previous_parent| {
+ child_connected = child.isConnected();
+ page.removeNode(previous_parent, child, .{ .will_be_reconnected = parent_is_connected });
+ }
+ try page.appendNode(parent, child, .{ .child_already_connected = child_connected });
+ }
+}
+
+pub fn remove(self: *Element, page: *Page) void {
+ page.domChanged();
+ const node = self.asNode();
+ const parent = node._parent orelse return;
+ page.removeNode(parent, node, .{ .will_be_reconnected = false });
+}
+
+pub fn focus(self: *Element, page: *Page) !void {
+ const Event = @import("Event.zig");
+
+ if (page.document._active_element) |old| {
+ if (old == self) {
+ return;
+ }
+
+ const blur_event = try Event.init("blur", null, page);
+ try page._event_manager.dispatch(old.asEventTarget(), blur_event);
+ }
+
+ if (self.asNode().isConnected()) {
+ page.document._active_element = self;
+ }
+
+ const focus_event = try Event.init("focus", null, page);
+ try page._event_manager.dispatch(self.asEventTarget(), focus_event);
+}
+
+pub fn blur(self: *Element, page: *Page) !void {
+ if (page.document._active_element != self) return;
+
+ page.document._active_element = null;
+
+ const Event = @import("Event.zig");
+ const blur_event = try Event.init("blur", null, page);
+ try page._event_manager.dispatch(self.asEventTarget(), blur_event);
+}
+
+pub fn getChildren(self: *Element, page: *Page) !collections.NodeLive(.child_elements) {
+ return collections.NodeLive(.child_elements).init(self.asNode(), {}, page);
+}
+
+pub fn append(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.appendChild(child, page);
+ }
+}
+
+pub fn prepend(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const parent = self.asNode();
+ var i = nodes.len;
+ while (i > 0) {
+ i -= 1;
+ const child = try nodes[i].toNode(page);
+ _ = try parent.insertBefore(child, parent.firstChild(), page);
+ }
+}
+
+pub fn before(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.insertBefore(child, node, page);
+ }
+}
+
+pub fn after(self: *Element, nodes: []const Node.NodeOrText, page: *Page) !void {
+ const node = self.asNode();
+ const parent = node.parentNode() orelse return;
+ const next = node.nextSibling();
+
+ for (nodes) |node_or_text| {
+ const child = try node_or_text.toNode(page);
+ _ = try parent.insertBefore(child, next, page);
+ }
+}
+
+pub fn firstElementChild(self: *Element) ?*Element {
+ var maybe_child = self.asNode().firstChild();
+ while (maybe_child) |child| {
+ if (child.is(Element)) |el| return el;
+ maybe_child = child.nextSibling();
+ }
+ return null;
+}
+
+pub fn lastElementChild(self: *Element) ?*Element {
+ var maybe_child = self.asNode().lastChild();
+ while (maybe_child) |child| {
+ if (child.is(Element)) |el| return el;
+ maybe_child = child.previousSibling();
+ }
+ return null;
+}
+
+pub fn nextElementSibling(self: *Element) ?*Element {
+ var maybe_sibling = self.asNode().nextSibling();
+ while (maybe_sibling) |sibling| {
+ if (sibling.is(Element)) |el| return el;
+ maybe_sibling = sibling.nextSibling();
+ }
+ return null;
+}
+
+pub fn previousElementSibling(self: *Element) ?*Element {
+ var maybe_sibling = self.asNode().previousSibling();
+ while (maybe_sibling) |sibling| {
+ if (sibling.is(Element)) |el| return el;
+ maybe_sibling = sibling.previousSibling();
+ }
+ return null;
+}
+
+pub fn getChildElementCount(self: *Element) usize {
+ var count: usize = 0;
+ var it = self.asNode().childrenIterator();
+ while (it.next()) |node| {
+ if (node.is(Element) != null) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+pub fn matches(self: *Element, selector: []const u8, page: *Page) !bool {
+ return Selector.matches(self, selector, page);
+}
+
+pub fn querySelector(self: *Element, selector: []const u8, page: *Page) !?*Element {
+ return Selector.querySelector(self.asNode(), selector, page);
+}
+
+pub fn querySelectorAll(self: *Element, input: []const u8, page: *Page) !*Selector.List {
+ return Selector.querySelectorAll(self.asNode(), input, page);
+}
+
+pub fn getAnimations(_: *const Element) []*Animation {
+ return &.{};
+}
+
+pub fn animate(_: *Element, _: js.Object, _: js.Object) !Animation {
+ return Animation.init();
+}
+
+pub fn closest(self: *Element, selector: []const u8, page: *Page) !?*Element {
+ if (selector.len == 0) {
+ return error.SyntaxError;
+ }
+
+ var current: ?*Element = self;
+ while (current) |el| {
+ if (try el.matches(selector, page)) {
+ return el;
+ }
+
+ const parent = el._proto._parent orelse break;
+
+ if (parent.is(ShadowRoot) != null) {
+ break;
+ }
+
+ current = parent.is(Element);
+ }
+ return null;
+}
+
+pub fn parentElement(self: *Element) ?*Element {
+ return self._proto.parentElement();
+}
+
+pub fn checkVisibility(self: *Element, page: *Page) !bool {
+ var current: ?*Element = self;
+
+ while (current) |el| {
+ const style = try el.getStyle(page);
+ const display = style.asCSSStyleDeclaration().getPropertyValue("display", page);
+ if (std.mem.eql(u8, display, "none")) {
+ return false;
+ }
+ current = el.parentElement();
+ }
+
+ return true;
+}
+
+fn getElementDimensions(self: *Element, page: *Page) !struct { width: f64, height: f64 } {
+ const style = try self.getStyle(page);
+ const decl = style.asCSSStyleDeclaration();
+ var width = CSS.parseDimension(decl.getPropertyValue("width", page)) orelse 5.0;
+ var height = CSS.parseDimension(decl.getPropertyValue("height", page)) orelse 5.0;
+
+ if (width == 5.0 or height == 5.0) {
+ const tag = self.getTag();
+
+ // Root containers get large default size to contain descendant positions.
+ // With calculateDocumentPosition using linear depth scaling (100px per level),
+ // even very deep trees (100 levels) stay within 10,000px.
+ // 100M pixels is plausible for very long documents.
+ if (tag == .html or tag == .body) {
+ if (width == 5.0) width = 1920.0;
+ if (height == 5.0) height = 100_000_000.0;
+ } else if (tag == .img or tag == .iframe) {
+ if (self.getAttributeSafe("width")) |w| {
+ width = std.fmt.parseFloat(f64, w) catch width;
+ }
+ if (self.getAttributeSafe("height")) |h| {
+ height = std.fmt.parseFloat(f64, h) catch height;
+ }
+ }
+ }
+
+ return .{ .width = width, .height = height };
+}
+
+pub fn getClientWidth(self: *Element, page: *Page) !f64 {
+ if (!try self.checkVisibility(page)) {
+ return 0.0;
+ }
+ const dims = try self.getElementDimensions(page);
+ return dims.width;
+}
+
+pub fn getClientHeight(self: *Element, page: *Page) !f64 {
+ if (!try self.checkVisibility(page)) {
+ return 0.0;
+ }
+ const dims = try self.getElementDimensions(page);
+ return dims.height;
+}
+
+pub fn getBoundingClientRect(self: *Element, page: *Page) !*DOMRect {
+ if (!try self.checkVisibility(page)) {
+ return page._factory.create(DOMRect{
+ ._x = 0.0,
+ ._y = 0.0,
+ ._width = 0.0,
+ ._height = 0.0,
+ ._top = 0.0,
+ ._right = 0.0,
+ ._bottom = 0.0,
+ ._left = 0.0,
+ });
+ }
+
+ const y = calculateDocumentPosition(self.asNode());
+ const dims = try self.getElementDimensions(page);
+
+ const x: f64 = 0.0;
+ const top = y;
+ const left = x;
+ const right = x + dims.width;
+ const bottom = y + dims.height;
+
+ return page._factory.create(DOMRect{
+ ._x = x,
+ ._y = y,
+ ._width = dims.width,
+ ._height = dims.height,
+ ._top = top,
+ ._right = right,
+ ._bottom = bottom,
+ ._left = left,
+ });
+}
+
+pub fn getClientRects(self: *Element, page: *Page) ![]DOMRect {
+ if (!try self.checkVisibility(page)) {
+ return &.{};
+ }
+ const ptr = try self.getBoundingClientRect(page);
+ return ptr[0..1];
+}
+
+// Calculates a pseudo-position in the document using linear depth scaling.
+//
+// This approach uses a fixed pixel offset per depth level (100px) plus sibling
+// position within that level. This keeps positions reasonable even for very deep
+// DOM trees (e.g., Amazon product pages can be 36+ levels deep).
+//
+// Example:
+// → position 0 (depth 0)
+// → position 100 (depth 1, 0 siblings)
+// → position 200 (depth 2, 0 siblings)
+// → position 201 (depth 2, 1 sibling)
+//
+// → position 101 (depth 1, 1 sibling)
+//
→ position 200 (depth 2, 0 siblings)
+//
+//
+//
+// Trade-offs:
+// - O(depth) complexity, very fast
+// - Linear scaling: 36 levels ≈ 3,600px, 100 levels ≈ 10,000px
+// - Rough document order preserved (depth dominates, siblings differentiate)
+// - Fits comfortably in realistic document heights
+fn calculateDocumentPosition(node: *Node) f64 {
+ var depth: f64 = 0.0;
+ var sibling_offset: f64 = 0.0;
+ var current = node;
+
+ // Count siblings at the immediate level
+ if (current.parentNode()) |parent| {
+ var sibling = parent.firstChild();
+ while (sibling) |s| {
+ if (s == current) break;
+ sibling_offset += 1.0;
+ sibling = s.nextSibling();
+ }
+ }
+
+ // Count depth from root
+ while (current.parentNode()) |parent| {
+ depth += 1.0;
+ current = parent;
+ }
+
+ // Each depth level = 100px, siblings add within that level
+ return (depth * 100.0) + sibling_offset;
+}
+
+const GetElementsByTagNameResult = union(enum) {
+ tag: collections.NodeLive(.tag),
+ tag_name: collections.NodeLive(.tag_name),
+};
+pub fn getElementsByTagName(self: *Element, tag_name: []const u8, page: *Page) !GetElementsByTagNameResult {
+ if (tag_name.len > 256) {
+ // 256 seems generous.
+ return error.InvalidTagName;
+ }
+
+ const lower = std.ascii.lowerString(&page.buf, tag_name);
+ if (Tag.parseForMatch(lower)) |known| {
+ // optimized for known tag names
+ return .{
+ .tag = collections.NodeLive(.tag).init(self.asNode(), known, page),
+ };
+ }
+
+ const arena = page.arena;
+ const filter = try String.init(arena, lower, .{});
+ return .{ .tag_name = collections.NodeLive(.tag_name).init(self.asNode(), filter, page) };
+}
+
+pub fn getElementsByClassName(self: *Element, class_name: []const u8, page: *Page) !collections.NodeLive(.class_name) {
+ const arena = page.arena;
+
+ // Parse space-separated class names
+ 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);
+ }
+
+ return collections.NodeLive(.class_name).init(self.asNode(), class_names.items, page);
+}
+
+pub fn cloneElement(self: *Element, deep: bool, page: *Page) !*Node {
+ const tag_name = self.getTagNameDump();
+ const namespace_uri = self.getNamespaceURI();
+
+ const node = try page.createElement(namespace_uri, tag_name, self._attributes);
+
+ if (deep) {
+ var child_it = self.asNode().childrenIterator();
+ while (child_it.next()) |child| {
+ const cloned_child = try child.cloneNode(true, page);
+ // We pass `true` to `child_already_connected` as a hacky optimization
+ // We _know_ this child isn't connected (Becasue the parent isn't connected)
+ // setting this to `true` skips all connection checks and just assumes t
+ try page.appendNode(node, cloned_child, .{ .child_already_connected = true });
+ }
+ }
+
+ return node;
+}
+
+pub fn scrollIntoViewIfNeeded(_: *const Element, center_if_needed: ?bool) void {
+ _ = center_if_needed;
+}
+
+pub fn format(self: *Element, writer: *std.Io.Writer) !void {
+ try writer.writeByte('<');
+ try writer.writeAll(self.getTagNameDump());
+
+ if (self._attributes) |attributes| {
+ var it = attributes.iterator();
+ while (it.next()) |attr| {
+ try writer.print(" {f}", .{attr});
+ }
+ }
+ try writer.writeByte('>');
+}
+
+fn upperTagName(tag_name: *String, buf: []u8) []const u8 {
+ if (tag_name.len > buf.len) {
+ log.info(.dom, "tag.long.name", .{ .name = tag_name.str() });
+ return tag_name.str();
+ }
+ const tag = tag_name.str();
+ // If the tag_name has a prefix, we must uppercase only the suffix part.
+ // example: te:st should be returned as te:ST.
+ if (std.mem.indexOfPos(u8, tag, 0, ":")) |pos| {
+ @memcpy(buf[0 .. pos + 1], tag[0 .. pos + 1]);
+ _ = std.ascii.upperString(buf[pos..tag.len], tag[pos..tag.len]);
+ return buf[0..tag.len];
+ }
+ return std.ascii.upperString(buf, tag);
+}
+
+pub fn getTag(self: *const Element) Tag {
+ return switch (self._type) {
+ .html => |he| switch (he._type) {
+ .anchor => .anchor,
+ .div => .div,
+ .embed => .embed,
+ .form => .form,
+ .p => .p,
+ .custom => .custom,
+ .data => .data,
+ .dialog => .dialog,
+ .iframe => .iframe,
+ .img => .img,
+ .br => .br,
+ .button => .button,
+ .heading => |h| h._tag,
+ .li => .li,
+ .ul => .ul,
+ .ol => .ol,
+ .generic => |g| g._tag,
+ .media => |m| switch (m._type) {
+ .audio => .audio,
+ .video => .video,
+ .generic => .media,
+ },
+ .script => .script,
+ .select => .select,
+ .slot => .slot,
+ .option => .option,
+ .template => .template,
+ .text_area => .textarea,
+ .input => .input,
+ .link => .link,
+ .meta => .meta,
+ .hr => .hr,
+ .style => .style,
+ .title => .title,
+ .body => .body,
+ .html => .html,
+ .head => .head,
+ .unknown => .unknown,
+ },
+ .svg => |se| switch (se._type) {
+ .svg => .svg,
+ .generic => |g| g._tag,
+ },
+ };
+}
+
+pub const Tag = enum {
+ anchor,
+ audio,
+ b,
+ body,
+ br,
+ button,
+ circle,
+ custom,
+ data,
+ dialog,
+ div,
+ embed,
+ ellipse,
+ em,
+ form,
+ g,
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ head,
+ header,
+ heading,
+ hr,
+ html,
+ i,
+ iframe,
+ img,
+ input,
+ li,
+ line,
+ link,
+ main,
+ meta,
+ media,
+ nav,
+ ol,
+ option,
+ p,
+ path,
+ polygon,
+ polyline,
+ rect,
+ script,
+ select,
+ slot,
+ span,
+ strong,
+ style,
+ svg,
+ text,
+ template,
+ textarea,
+ title,
+ ul,
+ video,
+ unknown,
+
+ // If the tag is "unknown", we can't use the optimized tag matching, but
+ // need to fallback to the actual tag name
+ pub fn parseForMatch(lower: []const u8) ?Tag {
+ const tag = std.meta.stringToEnum(Tag, lower) orelse return null;
+ return switch (tag) {
+ .unknown, .custom => null,
+ else => tag,
+ };
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Element);
+
+ pub const Meta = struct {
+ pub const name = "Element";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const tagName = bridge.accessor(_tagName, null, .{});
+ fn _tagName(self: *Element, page: *Page) []const u8 {
+ return self.getTagNameSpec(&page.buf);
+ }
+ pub const namespaceURI = bridge.accessor(Element.getNamespaceURI, null, .{});
+
+ pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{});
+ fn _innerText(self: *Element, page: *const Page) ![]const u8 {
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.getInnerText(&buf.writer);
+ return buf.written();
+ }
+
+ pub const outerHTML = bridge.accessor(_outerHTML, null, .{});
+ fn _outerHTML(self: *Element, page: *Page) ![]const u8 {
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.getOuterHTML(&buf.writer, page);
+ return buf.written();
+ }
+
+ pub const innerHTML = bridge.accessor(_innerHTML, Element.setInnerHTML, .{});
+ fn _innerHTML(self: *Element, page: *Page) ![]const u8 {
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.getInnerHTML(&buf.writer, page);
+ return buf.written();
+ }
+
+ pub const prefix = bridge.accessor(_prefix, null, .{});
+ fn _prefix(self: *Element) ?[]const u8 {
+ const name = self.getTagNameLower();
+ if (std.mem.indexOfPos(u8, name, 0, ":")) |pos| {
+ return name[0..pos];
+ }
+
+ return null;
+ }
+
+ pub const localName = bridge.accessor(Element.getLocalName, null, .{});
+ pub const id = bridge.accessor(Element.getId, Element.setId, .{});
+ pub const dir = bridge.accessor(Element.getDir, Element.setDir, .{});
+ pub const className = bridge.accessor(Element.getClassName, Element.setClassName, .{});
+ pub const classList = bridge.accessor(Element.getClassList, null, .{});
+ pub const dataset = bridge.accessor(Element.getDataset, null, .{});
+ pub const style = bridge.accessor(Element.getStyle, null, .{});
+ pub const attributes = bridge.accessor(Element.getAttributeNamedNodeMap, null, .{});
+ pub const hasAttribute = bridge.function(Element.hasAttribute, .{});
+ pub const hasAttributes = bridge.function(Element.hasAttributes, .{});
+ pub const getAttribute = bridge.function(Element.getAttribute, .{});
+ pub const getAttributeNode = bridge.function(Element.getAttributeNode, .{});
+ pub const setAttribute = bridge.function(Element.setAttribute, .{ .dom_exception = true });
+ pub const setAttributeNode = bridge.function(Element.setAttributeNode, .{});
+ pub const removeAttribute = bridge.function(Element.removeAttribute, .{});
+ pub const toggleAttribute = bridge.function(Element.toggleAttribute, .{ .dom_exception = true });
+ pub const getAttributeNames = bridge.function(Element.getAttributeNames, .{});
+ pub const removeAttributeNode = bridge.function(Element.removeAttributeNode, .{ .dom_exception = true });
+ pub const shadowRoot = bridge.accessor(Element.getShadowRoot, null, .{});
+ pub const attachShadow = bridge.function(_attachShadow, .{ .dom_exception = true });
+ pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .dom_exception = true });
+ pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .dom_exception = true });
+ pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .dom_exception = true });
+
+ const ShadowRootInit = struct {
+ mode: []const u8,
+ };
+ fn _attachShadow(self: *Element, init: ShadowRootInit, page: *Page) !*ShadowRoot {
+ return self.attachShadow(init.mode, page);
+ }
+ pub const replaceChildren = bridge.function(Element.replaceChildren, .{});
+ pub const remove = bridge.function(Element.remove, .{});
+ pub const append = bridge.function(Element.append, .{});
+ pub const prepend = bridge.function(Element.prepend, .{});
+ pub const before = bridge.function(Element.before, .{});
+ pub const after = bridge.function(Element.after, .{});
+ pub const firstElementChild = bridge.accessor(Element.firstElementChild, null, .{});
+ pub const lastElementChild = bridge.accessor(Element.lastElementChild, null, .{});
+ pub const nextElementSibling = bridge.accessor(Element.nextElementSibling, null, .{});
+ pub const previousElementSibling = bridge.accessor(Element.previousElementSibling, null, .{});
+ pub const childElementCount = bridge.accessor(Element.getChildElementCount, null, .{});
+ pub const matches = bridge.function(Element.matches, .{ .dom_exception = true });
+ pub const querySelector = bridge.function(Element.querySelector, .{ .dom_exception = true });
+ pub const querySelectorAll = bridge.function(Element.querySelectorAll, .{ .dom_exception = true });
+ pub const closest = bridge.function(Element.closest, .{ .dom_exception = true });
+ pub const getAnimations = bridge.function(Element.getAnimations, .{});
+ pub const animate = bridge.function(Element.animate, .{});
+ pub const checkVisibility = bridge.function(Element.checkVisibility, .{});
+ pub const clientWidth = bridge.accessor(Element.getClientWidth, null, .{});
+ pub const clientHeight = bridge.accessor(Element.getClientHeight, null, .{});
+ pub const getClientRects = bridge.function(Element.getClientRects, .{});
+ pub const getBoundingClientRect = bridge.function(Element.getBoundingClientRect, .{});
+ pub const getElementsByTagName = bridge.function(Element.getElementsByTagName, .{});
+ pub const getElementsByClassName = bridge.function(Element.getElementsByClassName, .{});
+ pub const children = bridge.accessor(Element.getChildren, null, .{});
+ pub const focus = bridge.function(Element.focus, .{});
+ pub const blur = bridge.function(Element.blur, .{});
+ pub const scrollIntoViewIfNeeded = bridge.function(Element.scrollIntoViewIfNeeded, .{});
+};
+
+pub const Build = struct {
+ // Calls `func_name` with `args` on the most specific type where it is
+ // implement. This could be on the Element itself.
+ pub fn call(self: *const Element, comptime func_name: []const u8, args: anytype) !bool {
+ inline for (@typeInfo(Element.Type).@"union".fields) |f| {
+ if (@field(Element.Type, f.name) == self._type) {
+ // The inner type implements this function. Call it and we're done.
+ const S = reflect.Struct(f.type);
+ if (@hasDecl(S, "Build")) {
+ if (@hasDecl(S.Build, "call")) {
+ const sub = @field(self._type, f.name);
+ return S.Build.call(sub, func_name, args);
+ }
+
+ // The inner type implements this function. Call it and we're done.
+ if (@hasDecl(f.type, func_name)) {
+ return @call(.auto, @field(f.type, func_name), args);
+ }
+ }
+ }
+ }
+
+ if (@hasDecl(Element.Build, func_name)) {
+ // Our last resort - the element implements this function.
+ try @call(.auto, @field(Element.Build, func_name), args);
+ return true;
+ }
+
+ // inform our caller (the Node) that we didn't find anything that implemented
+ // func_name and it should keep searching for a match.
+ return false;
+ }
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Element" {
+ try testing.htmlRunner("element", .{});
+}
diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig
new file mode 100644
index 000000000..e99d71ece
--- /dev/null
+++ b/src/browser/webapi/Event.zig
@@ -0,0 +1,279 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const reflect = @import("../reflect.zig");
+
+const Page = @import("../Page.zig");
+const EventTarget = @import("EventTarget.zig");
+const Node = @import("Node.zig");
+const String = @import("../../string.zig").String;
+
+pub const Event = @This();
+
+const _prototype_root = true;
+_type: Type,
+
+_bubbles: bool = false,
+_cancelable: bool = false,
+_composed: bool = false,
+_type_string: String,
+_target: ?*EventTarget = null,
+_current_target: ?*EventTarget = null,
+_prevent_default: bool = false,
+_stop_propagation: bool = false,
+_stop_immediate_propagation: bool = false,
+_event_phase: EventPhase = .none,
+_time_stamp: u64 = 0,
+_needs_retargeting: bool = false,
+
+pub const EventPhase = enum(u8) {
+ none = 0,
+ capturing_phase = 1,
+ at_target = 2,
+ bubbling_phase = 3,
+};
+
+pub const Type = union(enum) {
+ generic,
+ error_event: *@import("event/ErrorEvent.zig"),
+ custom_event: *@import("event/CustomEvent.zig"),
+ message_event: *@import("event/MessageEvent.zig"),
+ progress_event: *@import("event/ProgressEvent.zig"),
+ composition_event: *@import("event/CompositionEvent.zig"),
+ navigation_current_entry_change_event: *@import("event/NavigationCurrentEntryChangeEvent.zig"),
+ page_transition_event: *@import("event/PageTransitionEvent.zig"),
+ pop_state_event: *@import("event/PopStateEvent.zig"),
+};
+
+const Options = struct {
+ bubbles: bool = false,
+ cancelable: bool = false,
+ composed: bool = false,
+};
+
+pub fn init(typ: []const u8, opts_: ?Options, page: *Page) !*Event {
+ const opts = opts_ orelse Options{};
+
+ // Round to 2ms for privacy (browsers do this)
+ const raw_timestamp = @import("../../datetime.zig").milliTimestamp(.monotonic);
+ const time_stamp = (raw_timestamp / 2) * 2;
+
+ return page._factory.create(Event{
+ ._type = .generic,
+ ._bubbles = opts.bubbles,
+ ._time_stamp = time_stamp,
+ ._cancelable = opts.cancelable,
+ ._composed = opts.composed,
+ ._type_string = try String.init(page.arena, typ, .{}),
+ });
+}
+
+pub fn getType(self: *const Event) []const u8 {
+ return self._type_string.str();
+}
+
+pub fn getBubbles(self: *const Event) bool {
+ return self._bubbles;
+}
+
+pub fn getCancelable(self: *const Event) bool {
+ return self._cancelable;
+}
+
+pub fn getComposed(self: *const Event) bool {
+ return self._composed;
+}
+
+pub fn getTarget(self: *const Event) ?*EventTarget {
+ return self._target;
+}
+
+pub fn getCurrentTarget(self: *const Event) ?*EventTarget {
+ return self._current_target;
+}
+
+pub fn preventDefault(self: *Event) void {
+ self._prevent_default = true;
+}
+
+pub fn stopPropagation(self: *Event) void {
+ self._stop_propagation = true;
+}
+
+pub fn stopImmediatePropagation(self: *Event) void {
+ self._stop_immediate_propagation = true;
+ self._stop_propagation = true;
+}
+
+pub fn getDefaultPrevented(self: *const Event) bool {
+ return self._prevent_default;
+}
+
+pub fn getEventPhase(self: *const Event) u8 {
+ return @intFromEnum(self._event_phase);
+}
+
+pub fn getTimeStamp(self: *const Event) u64 {
+ return self._time_stamp;
+}
+
+pub fn composedPath(self: *Event, page: *Page) ![]const *EventTarget {
+ // Return empty array if event is not being dispatched
+ if (self._event_phase == .none) {
+ return &.{};
+ }
+
+ // If there's no target, return empty array
+ const target = self._target orelse return &.{};
+
+ // Only nodes have a propagation path
+ const target_node = switch (target._type) {
+ .node => |n| n,
+ else => return &.{},
+ };
+
+ // Build the path by walking up from target
+ var path_len: usize = 0;
+ var path_buffer: [128]*EventTarget = undefined;
+ var stopped_at_shadow_boundary = false;
+
+ var node: ?*Node = target_node;
+ while (node) |n| {
+ if (path_len >= path_buffer.len) {
+ break;
+ }
+ path_buffer[path_len] = n.asEventTarget();
+ path_len += 1;
+
+ // Check if this node is a shadow root
+ if (n._type == .document_fragment) {
+ if (n._type.document_fragment._type == .shadow_root) {
+ const shadow = n._type.document_fragment._type.shadow_root;
+
+ // If event is not composed, stop at shadow boundary
+ if (!self._composed) {
+ stopped_at_shadow_boundary = true;
+ break;
+ }
+
+ // Otherwise, jump to the shadow host and continue
+ node = shadow._host.asNode();
+ continue;
+ }
+ }
+
+ node = n._parent;
+ }
+
+ // Add window at the end (unless we stopped at shadow boundary)
+ if (!stopped_at_shadow_boundary) {
+ if (path_len < path_buffer.len) {
+ path_buffer[path_len] = page.window.asEventTarget();
+ path_len += 1;
+ }
+ }
+
+ // Allocate and return the path using call_arena (short-lived)
+ const path = try page.call_arena.alloc(*EventTarget, path_len);
+ @memcpy(path, path_buffer[0..path_len]);
+ return path;
+}
+
+pub fn populateFromOptions(self: *Event, opts: anytype) void {
+ self._bubbles = opts.bubbles;
+ self._cancelable = opts.cancelable;
+ self._composed = opts.composed;
+}
+
+pub fn inheritOptions(comptime T: type, comptime additions: anytype) type {
+ var all_fields: []const std.builtin.Type.StructField = &.{};
+
+ if (@hasField(T, "_proto")) {
+ const t_fields = @typeInfo(T).@"struct".fields;
+
+ inline for (t_fields) |field| {
+ if (std.mem.eql(u8, field.name, "_proto")) {
+ const ProtoType = reflect.Struct(field.type);
+ if (@hasDecl(ProtoType, "Options")) {
+ const parent_options = @typeInfo(ProtoType.Options);
+ all_fields = all_fields ++ parent_options.@"struct".fields;
+ }
+ }
+ }
+ }
+
+ const additions_info = @typeInfo(additions);
+ all_fields = all_fields ++ additions_info.@"struct".fields;
+
+ return @Type(.{
+ .@"struct" = .{
+ .layout = .auto,
+ .fields = all_fields,
+ .decls = &.{},
+ .is_tuple = false,
+ },
+ });
+}
+
+pub fn populatePrototypes(self: anytype, opts: anytype) void {
+ const T = @TypeOf(self.*);
+
+ if (@hasField(T, "_proto")) {
+ populatePrototypes(self._proto, opts);
+ }
+
+ if (@hasDecl(T, "populateFromOptions")) {
+ T.populateFromOptions(self, opts);
+ }
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Event);
+
+ pub const Meta = struct {
+ pub const name = "Event";
+
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Event.init, .{});
+ pub const @"type" = bridge.accessor(Event.getType, null, .{});
+ pub const bubbles = bridge.accessor(Event.getBubbles, null, .{});
+ 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 currentTarget = bridge.accessor(Event.getCurrentTarget, null, .{});
+ pub const eventPhase = bridge.accessor(Event.getEventPhase, null, .{});
+ pub const defaultPrevented = bridge.accessor(Event.getDefaultPrevented, null, .{});
+ pub const timeStamp = bridge.accessor(Event.getTimeStamp, null, .{});
+ pub const preventDefault = bridge.function(Event.preventDefault, .{});
+ pub const stopPropagation = bridge.function(Event.stopPropagation, .{});
+ pub const stopImmediatePropagation = bridge.function(Event.stopImmediatePropagation, .{});
+ pub const composedPath = bridge.function(Event.composedPath, .{});
+
+ // Event phase constants
+ pub const NONE = bridge.property(@intFromEnum(EventPhase.none));
+ pub const CAPTURING_PHASE = bridge.property(@intFromEnum(EventPhase.capturing_phase));
+ pub const AT_TARGET = bridge.property(@intFromEnum(EventPhase.at_target));
+ pub const BUBBLING_PHASE = bridge.property(@intFromEnum(EventPhase.bubbling_phase));
+};
+
+// tested in event_target
diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig
new file mode 100644
index 000000000..b399f7cb7
--- /dev/null
+++ b/src/browser/webapi/EventTarget.zig
@@ -0,0 +1,136 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const RegisterOptions = @import("../EventManager.zig").RegisterOptions;
+
+const Event = @import("Event.zig");
+
+const EventTarget = @This();
+
+const _prototype_root = true;
+_type: Type,
+
+pub const Type = union(enum) {
+ node: *@import("Node.zig"),
+ window: *@import("Window.zig"),
+ xhr: *@import("net/XMLHttpRequestEventTarget.zig"),
+ abort_signal: *@import("AbortSignal.zig"),
+ media_query_list: *@import("css/MediaQueryList.zig"),
+ message_port: *@import("MessagePort.zig"),
+ text_track_cue: *@import("media/TextTrackCue.zig"),
+ navigation: *@import("navigation/NavigationEventTarget.zig"),
+ screen: *@import("Screen.zig"),
+ screen_orientation: *@import("Screen.zig").Orientation,
+};
+
+pub fn dispatchEvent(self: *EventTarget, event: *Event, page: *Page) !bool {
+ try page._event_manager.dispatch(self, event);
+ return !event._cancelable or !event._prevent_default;
+}
+
+const AddEventListenerOptions = union(enum) {
+ capture: bool,
+ options: RegisterOptions,
+};
+
+pub const EventListenerCallback = union(enum) {
+ function: js.Function,
+ object: js.Object,
+};
+pub fn addEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?AddEventListenerOptions, page: *Page) !void {
+ const callback = callback_ orelse return;
+
+ const actual_callback = switch (callback) {
+ .function => |func| func,
+ .object => |obj| (try obj.getFunction("handleEvent")) orelse return,
+ };
+
+ const options = blk: {
+ const o = opts_ orelse break :blk RegisterOptions{};
+ break :blk switch (o) {
+ .options => |opts| opts,
+ .capture => |capture| RegisterOptions{ .capture = capture },
+ };
+ };
+ return page._event_manager.register(self, typ, actual_callback, options);
+}
+
+const RemoveEventListenerOptions = union(enum) {
+ capture: bool,
+ options: Options,
+
+ const Options = struct {
+ useCapture: bool = false,
+ };
+};
+pub fn removeEventListener(self: *EventTarget, typ: []const u8, callback_: ?EventListenerCallback, opts_: ?RemoveEventListenerOptions, page: *Page) !void {
+ const callback = callback_ orelse return;
+
+ const actual_callback = switch (callback) {
+ .function => |func| func,
+ .object => |obj| (try obj.getFunction("handleEvent")) orelse return,
+ };
+
+ const use_capture = blk: {
+ const o = opts_ orelse break :blk false;
+ break :blk switch (o) {
+ .capture => |capture| capture,
+ .options => |opts| opts.useCapture,
+ };
+ };
+ return page._event_manager.remove(self, typ, actual_callback, use_capture);
+}
+
+pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
+ return switch (self._type) {
+ .node => |n| n.format(writer),
+ .window => writer.writeAll(""),
+ .xhr => writer.writeAll(""),
+ .abort_signal => writer.writeAll(""),
+ .media_query_list => writer.writeAll(""),
+ .message_port => writer.writeAll(""),
+ .text_track_cue => writer.writeAll(""),
+ };
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(EventTarget);
+
+ pub const Meta = struct {
+ pub const name = "EventTarget";
+
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const dispatchEvent = bridge.function(EventTarget.dispatchEvent, .{});
+ pub const addEventListener = bridge.function(EventTarget.addEventListener, .{});
+ pub const removeEventListener = bridge.function(EventTarget.removeEventListener, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: EventTarget" {
+ // we create thousands of these per page. Nothing should bloat it.
+ try testing.expectEqual(16, @sizeOf(EventTarget));
+
+ try testing.htmlRunner("events.html", .{});
+}
diff --git a/src/browser/encoding/TextEncoder.zig b/src/browser/webapi/File.zig
similarity index 59%
rename from src/browser/encoding/TextEncoder.zig
rename to src/browser/webapi/File.zig
index 4ac32cd83..a67a8a6f4 100644
--- a/src/browser/encoding/TextEncoder.zig
+++ b/src/browser/webapi/File.zig
@@ -18,31 +18,33 @@
const std = @import("std");
+const Page = @import("../Page.zig");
+const Blob = @import("Blob.zig");
const js = @import("../js/js.zig");
-// https://encoding.spec.whatwg.org/#interface-textencoder
-const TextEncoder = @This();
+const File = @This();
-pub fn constructor() !TextEncoder {
- return .{};
-}
+/// `File` inherits `Blob`.
+_proto: *Blob,
-pub fn get_encoding(_: *const TextEncoder) []const u8 {
- return "utf-8";
+// TODO: Implement File API.
+pub fn init(page: *Page) !*File {
+ return page._factory.blob(File{ ._proto = undefined });
}
-pub fn _encode(_: *const TextEncoder, v: []const u8) !js.TypedArray(u8) {
- // Ensure the input is a valid utf-8
- // It seems chrome accepts invalid utf-8 sequence.
- //
- if (!std.unicode.utf8ValidateSlice(v)) {
- return error.InvalidUtf8;
- }
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(File);
- return .{ .values = v };
-}
+ pub const Meta = struct {
+ pub const name = "File";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(File.init, .{});
+};
const testing = @import("../../testing.zig");
-test "Browser: Encoding.TextEncoder" {
- try testing.htmlRunner("encoding/encoder.html");
+test "WebApi: File" {
+ try testing.htmlRunner("file.html", .{});
}
diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig
new file mode 100644
index 000000000..1d6dedc41
--- /dev/null
+++ b/src/browser/webapi/HTMLDocument.zig
@@ -0,0 +1,197 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const String = @import("../../string.zig").String;
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const Document = @import("Document.zig");
+const Element = @import("Element.zig");
+const collections = @import("collections.zig");
+
+const HTMLDocument = @This();
+
+_proto: *Document,
+
+pub fn asDocument(self: *HTMLDocument) *Document {
+ return self._proto;
+}
+
+pub fn asNode(self: *HTMLDocument) *Node {
+ return self._proto.asNode();
+}
+
+pub fn asEventTarget(self: *HTMLDocument) *@import("EventTarget.zig") {
+ return self._proto.asEventTarget();
+}
+
+pub fn className(_: *const HTMLDocument) []const u8 {
+ return "[object HTMLDocument]";
+}
+
+// HTML-specific accessors
+pub fn getHead(self: *HTMLDocument) ?*Element.Html.Head {
+ const doc_el = self._proto.getDocumentElement() orelse return null;
+ var child = doc_el.asNode().firstChild();
+ while (child) |node| {
+ if (node.is(Element.Html.Head)) |head| {
+ return head;
+ }
+ child = node.nextSibling();
+ }
+ return null;
+}
+
+pub fn getBody(self: *HTMLDocument) ?*Element.Html.Body {
+ const doc_el = self._proto.getDocumentElement() orelse return null;
+ var child = doc_el.asNode().firstChild();
+ while (child) |node| {
+ if (node.is(Element.Html.Body)) |body| {
+ return body;
+ }
+ child = node.nextSibling();
+ }
+ return null;
+}
+
+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();
+ }
+ }
+ return "";
+}
+
+pub fn setTitle(self: *HTMLDocument, title: []const u8, page: *Page) !void {
+ const head = self.getHead() orelse return;
+ 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);
+ }
+ }
+
+ const title_node = try page.createElement(null, "title", null);
+ const title_element = title_node.as(Element);
+ try title_element.replaceChildren(&.{.{ .text = title }}, page);
+ _ = try head.asNode().appendChild(title_node, page);
+}
+
+pub fn getImages(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
+ return collections.NodeLive(.tag).init(self.asNode(), .img, page);
+}
+
+pub fn getScripts(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
+ return collections.NodeLive(.tag).init(self.asNode(), .script, page);
+}
+
+pub fn getLinks(self: *HTMLDocument, page: *Page) !collections.NodeLive(.links) {
+ return collections.NodeLive(.links).init(self.asNode(), {}, page);
+}
+
+pub fn getAnchors(self: *HTMLDocument, page: *Page) !collections.NodeLive(.anchors) {
+ return collections.NodeLive(.anchors).init(self.asNode(), {}, page);
+}
+
+pub fn getForms(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
+ return collections.NodeLive(.tag).init(self.asNode(), .form, page);
+}
+
+pub fn getEmbeds(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag) {
+ return collections.NodeLive(.tag).init(self.asNode(), .embed, page);
+}
+
+const applet_string = String.init(undefined, "applet", .{}) catch unreachable;
+pub fn getApplets(self: *HTMLDocument, page: *Page) !collections.NodeLive(.tag_name) {
+ return collections.NodeLive(.tag_name).init(self.asNode(), applet_string, page);
+}
+
+pub fn getCurrentScript(self: *const HTMLDocument) ?*Element.Html.Script {
+ return self._proto._current_script;
+}
+
+pub fn getLocation(self: *const HTMLDocument) ?*@import("Location.zig") {
+ return self._proto._location;
+}
+
+pub fn getAll(self: *HTMLDocument, page: *Page) !*collections.HTMLAllCollection {
+ return page._factory.create(collections.HTMLAllCollection.init(self.asNode(), page));
+}
+
+pub fn getCookie(_: *HTMLDocument, page: *Page) ![]const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ try page._session.cookie_jar.forRequest(page.url, buf.writer(page.call_arena), .{
+ .is_http = false,
+ .is_navigation = true,
+ });
+ return buf.items;
+}
+
+pub fn setCookie(_: *HTMLDocument, cookie_str: []const u8, page: *Page) ![]const u8 {
+ // we use the cookie jar's allocator to parse the cookie because it
+ // outlives the page's arena.
+ const Cookie = @import("storage/Cookie.zig");
+ const c = try Cookie.parse(page._session.cookie_jar.allocator, page.url, cookie_str);
+ errdefer c.deinit();
+ if (c.http_only) {
+ c.deinit();
+ return ""; // HttpOnly cookies cannot be set from JS
+ }
+ try page._session.cookie_jar.add(c, std.time.timestamp());
+ return cookie_str;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(HTMLDocument);
+
+ pub const Meta = struct {
+ pub const name = "HTMLDocument";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(_constructor, .{});
+ fn _constructor(page: *Page) !*HTMLDocument {
+ return page._factory.document(HTMLDocument{
+ ._proto = undefined,
+ });
+ }
+
+ pub const head = bridge.accessor(HTMLDocument.getHead, null, .{});
+ pub const body = bridge.accessor(HTMLDocument.getBody, null, .{});
+ pub const title = bridge.accessor(HTMLDocument.getTitle, HTMLDocument.setTitle, .{});
+ pub const images = bridge.accessor(HTMLDocument.getImages, null, .{});
+ pub const scripts = bridge.accessor(HTMLDocument.getScripts, null, .{});
+ pub const links = bridge.accessor(HTMLDocument.getLinks, null, .{});
+ pub const anchors = bridge.accessor(HTMLDocument.getAnchors, null, .{});
+ pub const forms = bridge.accessor(HTMLDocument.getForms, null, .{});
+ pub const embeds = bridge.accessor(HTMLDocument.getEmbeds, null, .{});
+ pub const applets = bridge.accessor(HTMLDocument.getApplets, null, .{});
+ pub const plugins = bridge.accessor(HTMLDocument.getEmbeds, null, .{});
+ pub const currentScript = bridge.accessor(HTMLDocument.getCurrentScript, null, .{});
+ pub const location = bridge.accessor(HTMLDocument.getLocation, null, .{ .cache = "location" });
+ pub const all = bridge.accessor(HTMLDocument.getAll, null, .{});
+ pub const cookie = bridge.accessor(HTMLDocument.getCookie, HTMLDocument.setCookie, .{});
+};
diff --git a/src/browser/webapi/History.zig b/src/browser/webapi/History.zig
new file mode 100644
index 000000000..214f7230e
--- /dev/null
+++ b/src/browser/webapi/History.zig
@@ -0,0 +1,110 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const PopStateEvent = @import("event/PopStateEvent.zig");
+
+const History = @This();
+
+pub fn getLength(_: *const History, page: *Page) u32 {
+ return @intCast(page._session.navigation._entries.items.len);
+}
+
+pub fn getState(_: *const History, page: *Page) !?js.Value {
+ if (page._session.navigation.getCurrentEntry()._state.value) |state| {
+ const value = try js.Value.fromJson(page.js, state);
+ return value;
+ } else return null;
+}
+
+pub fn pushState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void {
+ const arena = page._session.arena;
+ const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url);
+
+ const json = state.toJson(arena) catch return error.DateClone;
+ _ = try page._session.navigation.pushEntry(url, .{ .source = .history, .value = json }, page, true);
+}
+
+pub fn replaceState(_: *History, state: js.Object, _: []const u8, _url: ?[]const u8, page: *Page) !void {
+ const arena = page._session.arena;
+ const url = if (_url) |u| try arena.dupeZ(u8, u) else try arena.dupeZ(u8, page.url);
+
+ const json = state.toJson(arena) catch return error.DateClone;
+ _ = try page._session.navigation.replaceEntry(url, .{ .source = .history, .value = json }, page, true);
+}
+
+fn goInner(delta: i32, page: *Page) !void {
+ // 0 behaves the same as no argument, both reloadig the page.
+
+ const current = page._session.navigation._index;
+ const index_s: i64 = @intCast(@as(i64, @intCast(current)) + @as(i64, @intCast(delta)));
+ if (index_s < 0 or index_s > page._session.navigation._entries.items.len - 1) {
+ return;
+ }
+
+ const index = @as(usize, @intCast(index_s));
+ const entry = page._session.navigation._entries.items[index];
+
+ if (entry._url) |url| {
+ if (try page.isSameOrigin(url)) {
+ const event = try PopStateEvent.init("popstate", .{ .state = entry._state.value }, page);
+
+ try page._event_manager.dispatchWithFunction(
+ page.window.asEventTarget(),
+ event.asEvent(),
+ page.window._on_popstate,
+ .{ .context = "Pop State" },
+ );
+ }
+ }
+
+ _ = try page._session.navigation.navigateInner(entry._url, .{ .traverse = index }, page);
+}
+
+pub fn back(_: *History, page: *Page) !void {
+ try goInner(-1, page);
+}
+
+pub fn forward(_: *History, page: *Page) !void {
+ try goInner(1, page);
+}
+
+pub fn go(_: *History, delta: ?i32, page: *Page) !void {
+ try goInner(delta orelse 0, page);
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(History);
+
+ pub const Meta = struct {
+ pub const name = "History";
+ pub var class_id: bridge.ClassId = 0;
+ pub const prototype_chain = bridge.prototypeChain();
+ };
+
+ pub const length = bridge.accessor(History.getLength, null, .{});
+ pub const state = bridge.accessor(History.getState, null, .{});
+ pub const pushState = bridge.function(History.pushState, .{});
+ pub const replaceState = bridge.function(History.replaceState, .{});
+ pub const back = bridge.function(History.back, .{});
+ pub const forward = bridge.function(History.forward, .{});
+ pub const go = bridge.function(History.go, .{});
+};
diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig
new file mode 100644
index 000000000..4666e5266
--- /dev/null
+++ b/src/browser/webapi/IntersectionObserver.zig
@@ -0,0 +1,324 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Element = @import("Element.zig");
+const DOMRect = @import("DOMRect.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ IntersectionObserver,
+ IntersectionObserverEntry,
+ };
+}
+
+const IntersectionObserver = @This();
+
+_callback: js.Function,
+_observing: std.ArrayList(*Element) = .{},
+_root: ?*Element = null,
+_root_margin: []const u8 = "0px",
+_threshold: []const f64 = &.{0.0},
+_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{},
+_previous_states: std.AutoHashMapUnmanaged(*Element, bool) = .{},
+
+// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements
+var zero_rect: DOMRect = .{
+ ._x = 0.0,
+ ._y = 0.0,
+ ._width = 0.0,
+ ._height = 0.0,
+ ._top = 0.0,
+ ._right = 0.0,
+ ._bottom = 0.0,
+ ._left = 0.0,
+};
+
+pub const ObserverInit = struct {
+ root: ?*Element = null,
+ rootMargin: ?[]const u8 = null,
+ threshold: Threshold = .{ .scalar = 0.0 },
+
+ const Threshold = union(enum) {
+ scalar: f64,
+ array: []const f64,
+ };
+};
+
+pub fn init(callback: js.Function, options: ?ObserverInit, page: *Page) !*IntersectionObserver {
+ const opts = options orelse ObserverInit{};
+ const root_margin = if (opts.rootMargin) |rm| try page.arena.dupe(u8, rm) else "0px";
+
+ const threshold = switch (opts.threshold) {
+ .scalar => |s| blk: {
+ const arr = try page.arena.alloc(f64, 1);
+ arr[0] = s;
+ break :blk arr;
+ },
+ .array => |arr| try page.arena.dupe(f64, arr),
+ };
+
+ return page._factory.create(IntersectionObserver{ ._callback = callback, ._root = opts.root, ._root_margin = root_margin, ._threshold = threshold });
+}
+
+pub fn observe(self: *IntersectionObserver, target: *Element, page: *Page) !void {
+ // Check if already observing this target
+ for (self._observing.items) |elem| {
+ if (elem == target) {
+ return;
+ }
+ }
+
+ // Register with page if this is our first observation
+ if (self._observing.items.len == 0) {
+ try page.registerIntersectionObserver(self);
+ }
+
+ try self._observing.append(page.arena, target);
+
+ // Don't initialize previous state yet - let checkIntersection do it
+ // This ensures we get an entry on first observation
+
+ // Check intersection for this new target and schedule delivery
+ try self.checkIntersection(target, page);
+ if (self._pending_entries.items.len > 0) {
+ try page.scheduleIntersectionDelivery();
+ }
+}
+
+pub fn unobserve(self: *IntersectionObserver, target: *Element) void {
+ for (self._observing.items, 0..) |elem, i| {
+ if (elem == target) {
+ _ = self._observing.swapRemove(i);
+ _ = self._previous_states.remove(target);
+
+ // Remove any pending entries for this target
+ var j: usize = 0;
+ while (j < self._pending_entries.items.len) {
+ if (self._pending_entries.items[j]._target == target) {
+ _ = self._pending_entries.swapRemove(j);
+ } else {
+ j += 1;
+ }
+ }
+ return;
+ }
+ }
+}
+
+pub fn disconnect(self: *IntersectionObserver, page: *Page) void {
+ page.unregisterIntersectionObserver(self);
+ self._observing.clearRetainingCapacity();
+ self._previous_states.clearRetainingCapacity();
+ self._pending_entries.clearRetainingCapacity();
+}
+
+pub fn takeRecords(self: *IntersectionObserver, page: *Page) ![]*IntersectionObserverEntry {
+ const entries = try page.call_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items);
+ self._pending_entries.clearRetainingCapacity();
+ return entries;
+}
+
+fn calculateIntersection(
+ self: *IntersectionObserver,
+ target: *Element,
+ page: *Page,
+) !IntersectionData {
+ const target_rect = try target.getBoundingClientRect(page);
+
+ // Use root element's rect or viewport (simplified: assume 1920x1080)
+ const root_rect = if (self._root) |root|
+ try root.getBoundingClientRect(page)
+ else
+ // Simplified viewport - assume 1920x1080 for now
+ try page._factory.create(DOMRect{
+ ._x = 0.0,
+ ._y = 0.0,
+ ._width = 1920.0,
+ ._height = 1080.0,
+ ._top = 0.0,
+ ._right = 1920.0,
+ ._bottom = 1080.0,
+ ._left = 0.0,
+ });
+
+ // For a headless browser without real layout, we treat all elements as fully visible.
+ // This avoids fingerprinting issues (massive viewports) and matches the behavior
+ // scripts expect when querying element visibility.
+ const is_intersecting = true;
+ const intersection_ratio: f64 = 1.0;
+
+ // Intersection rect is the same as the target rect (fully visible)
+ const intersection_rect = target_rect;
+
+ return .{
+ .is_intersecting = is_intersecting,
+ .intersection_ratio = intersection_ratio,
+ .intersection_rect = intersection_rect,
+ .bounding_client_rect = target_rect,
+ .root_bounds = root_rect,
+ };
+}
+
+const IntersectionData = struct {
+ is_intersecting: bool,
+ intersection_ratio: f64,
+ intersection_rect: *DOMRect,
+ bounding_client_rect: *DOMRect,
+ root_bounds: *DOMRect,
+};
+
+fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool {
+ for (self._threshold) |threshold| {
+ if (ratio >= threshold) {
+ return true;
+ }
+ }
+ return false;
+}
+
+fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page) !void {
+ const data = try self.calculateIntersection(target, page);
+ const was_intersecting_opt = self._previous_states.get(target);
+ const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio);
+
+ // Create entry if:
+ // 1. First time observing this target (was_intersecting_opt == null)
+ // 2. State changed
+ // 3. Currently intersecting
+ const should_report = was_intersecting_opt == null or
+ was_intersecting_opt.? != is_now_intersecting;
+
+ if (should_report) {
+ const entry = try page.arena.create(IntersectionObserverEntry);
+ entry.* = .{
+ ._target = target,
+ ._time = 0.0, // TODO: Get actual timestamp
+ ._bounding_client_rect = data.bounding_client_rect,
+ ._intersection_rect = data.intersection_rect,
+ ._root_bounds = data.root_bounds,
+ ._intersection_ratio = data.intersection_ratio,
+ ._is_intersecting = is_now_intersecting,
+ };
+
+ try self._pending_entries.append(page.arena, entry);
+ try self._previous_states.put(page.arena, target, is_now_intersecting);
+ }
+}
+
+pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void {
+ if (self._observing.items.len == 0) {
+ return;
+ }
+
+ for (self._observing.items) |target| {
+ try self.checkIntersection(target, page);
+ }
+
+ if (self._pending_entries.items.len > 0) {
+ try page.scheduleIntersectionDelivery();
+ }
+}
+
+pub fn deliverEntries(self: *IntersectionObserver, page: *Page) !void {
+ if (self._pending_entries.items.len == 0) {
+ return;
+ }
+
+ const entries = try self.takeRecords(page);
+ try self._callback.call(void, .{ entries, self });
+}
+
+pub const IntersectionObserverEntry = struct {
+ _target: *Element,
+ _time: f64,
+ _bounding_client_rect: *DOMRect,
+ _intersection_rect: *DOMRect,
+ _root_bounds: *DOMRect,
+ _intersection_ratio: f64,
+ _is_intersecting: bool,
+
+ pub fn getTarget(self: *const IntersectionObserverEntry) *Element {
+ return self._target;
+ }
+
+ pub fn getTime(self: *const IntersectionObserverEntry) f64 {
+ return self._time;
+ }
+
+ pub fn getBoundingClientRect(self: *const IntersectionObserverEntry) *DOMRect {
+ return self._bounding_client_rect;
+ }
+
+ pub fn getIntersectionRect(self: *const IntersectionObserverEntry) *DOMRect {
+ return self._intersection_rect;
+ }
+
+ pub fn getRootBounds(self: *const IntersectionObserverEntry) ?*DOMRect {
+ return self._root_bounds;
+ }
+
+ pub fn getIntersectionRatio(self: *const IntersectionObserverEntry) f64 {
+ return self._intersection_ratio;
+ }
+
+ pub fn getIsIntersecting(self: *const IntersectionObserverEntry) bool {
+ return self._is_intersecting;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(IntersectionObserverEntry);
+
+ pub const Meta = struct {
+ pub const name = "IntersectionObserverEntry";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{});
+ pub const time = bridge.accessor(IntersectionObserverEntry.getTime, null, .{});
+ pub const boundingClientRect = bridge.accessor(IntersectionObserverEntry.getBoundingClientRect, null, .{});
+ pub const intersectionRect = bridge.accessor(IntersectionObserverEntry.getIntersectionRect, null, .{});
+ pub const rootBounds = bridge.accessor(IntersectionObserverEntry.getRootBounds, null, .{});
+ pub const intersectionRatio = bridge.accessor(IntersectionObserverEntry.getIntersectionRatio, null, .{});
+ pub const isIntersecting = bridge.accessor(IntersectionObserverEntry.getIsIntersecting, null, .{});
+ };
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(IntersectionObserver);
+
+ pub const Meta = struct {
+ pub const name = "IntersectionObserver";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(init, .{});
+
+ pub const observe = bridge.function(IntersectionObserver.observe, .{});
+ pub const unobserve = bridge.function(IntersectionObserver.unobserve, .{});
+ pub const disconnect = bridge.function(IntersectionObserver.disconnect, .{});
+ pub const takeRecords = bridge.function(IntersectionObserver.takeRecords, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: IntersectionObserver" {
+ try testing.htmlRunner("intersection_observer", .{});
+}
diff --git a/src/browser/webapi/KeyValueList.zig b/src/browser/webapi/KeyValueList.zig
new file mode 100644
index 000000000..d94eacb5f
--- /dev/null
+++ b/src/browser/webapi/KeyValueList.zig
@@ -0,0 +1,212 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const String = @import("../../string.zig").String;
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+
+const Allocator = std.mem.Allocator;
+
+pub fn registerTypes() []const type {
+ return &.{
+ KeyIterator,
+ ValueIterator,
+ EntryIterator,
+ };
+}
+
+const Normalizer = *const fn ([]const u8, *Page) []const u8;
+pub const KeyValueList = @This();
+
+_entries: std.ArrayListUnmanaged(Entry) = .empty,
+
+pub const empty: KeyValueList = .{
+ ._entries = .empty,
+};
+
+pub fn copy(arena: Allocator, original: KeyValueList) !KeyValueList {
+ var list = KeyValueList.init();
+ try list.ensureTotalCapacity(arena, original.len());
+ for (original._entries.items) |entry| {
+ try list.appendAssumeCapacity(arena, entry.name.str(), entry.value.str());
+ }
+ return list;
+}
+
+pub fn fromJsObject(arena: Allocator, js_obj: js.Object, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList {
+ var it = js_obj.nameIterator();
+ var list = KeyValueList.init();
+ try list.ensureTotalCapacity(arena, it.count);
+
+ while (try it.next()) |name| {
+ const js_value = try js_obj.get(name);
+ const value = try js_value.toString(arena);
+ const normalized = if (comptime normalizer) |n| n(name, page) else name;
+
+ list._entries.appendAssumeCapacity(.{
+ .name = try String.init(arena, normalized, .{}),
+ .value = try String.init(arena, value, .{}),
+ });
+ }
+
+ return list;
+}
+
+pub fn fromArray(arena: Allocator, kvs: []const [2][]const u8, comptime normalizer: ?Normalizer, page: *Page) !KeyValueList {
+ var list = KeyValueList.init();
+ try list.ensureTotalCapacity(arena, kvs.len);
+
+ for (kvs) |pair| {
+ const normalized = if (comptime normalizer) |n| n(pair[0], page) else pair[0];
+
+ list._entries.appendAssumeCapacity(.{
+ .name = try String.init(arena, normalized, .{}),
+ .value = try String.init(arena, pair[1], .{}),
+ });
+ }
+ return list;
+}
+
+pub const Entry = struct {
+ name: String,
+ value: String,
+
+ pub fn format(self: Entry, writer: *std.Io.Writer) !void {
+ return writer.print("{f}: {f}", .{ self.name, self.value });
+ }
+};
+
+pub fn init() KeyValueList {
+ return .{};
+}
+
+pub fn ensureTotalCapacity(self: *KeyValueList, allocator: Allocator, n: usize) !void {
+ return self._entries.ensureTotalCapacity(allocator, n);
+}
+
+pub fn get(self: *const KeyValueList, name: []const u8) ?[]const u8 {
+ for (self._entries.items) |*entry| {
+ if (entry.name.eqlSlice(name)) {
+ return entry.value.str();
+ }
+ }
+ return null;
+}
+
+pub fn getAll(self: *const KeyValueList, name: []const u8, page: *Page) ![]const []const u8 {
+ const arena = page.call_arena;
+ var arr: std.ArrayList([]const u8) = .empty;
+ for (self._entries.items) |*entry| {
+ if (entry.name.eqlSlice(name)) {
+ try arr.append(arena, entry.value.str());
+ }
+ }
+ return arr.items;
+}
+
+pub fn has(self: *const KeyValueList, name: []const u8) bool {
+ for (self._entries.items) |*entry| {
+ if (entry.name.eqlSlice(name)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+pub fn append(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void {
+ try self._entries.append(allocator, .{
+ .name = try String.init(allocator, name, .{}),
+ .value = try String.init(allocator, value, .{}),
+ });
+}
+
+pub fn appendAssumeCapacity(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void {
+ self._entries.appendAssumeCapacity(.{
+ .name = try String.init(allocator, name, .{}),
+ .value = try String.init(allocator, value, .{}),
+ });
+}
+
+pub fn delete(self: *KeyValueList, name: []const u8, value: ?[]const u8) void {
+ var i: usize = 0;
+ while (i < self._entries.items.len) {
+ const entry = self._entries.items[i];
+ if (entry.name.eqlSlice(name)) {
+ if (value == null or entry.value.eqlSlice(value.?)) {
+ _ = self._entries.swapRemove(i);
+ continue;
+ }
+ }
+ i += 1;
+ }
+}
+
+pub fn set(self: *KeyValueList, allocator: Allocator, name: []const u8, value: []const u8) !void {
+ self.delete(name, null);
+ try self.append(allocator, name, value);
+}
+
+pub fn len(self: *const KeyValueList) usize {
+ return self._entries.items.len;
+}
+
+pub fn items(self: *const KeyValueList) []const Entry {
+ return self._entries.items;
+}
+
+pub const Iterator = struct {
+ index: u32 = 0,
+ kv: *KeyValueList,
+
+ // Why? Because whenever an Iterator is created, we need to increment the
+ // RC of what it's iterating. And when the iterator is destroyed, we need
+ // to decrement it. The generic iterator which will wrap this handles that
+ // by using this "list" field. Most things that use the GenericIterator can
+ // just set `list: *ZigCollection`, and everything will work. But KeyValueList
+ // is being composed by various types, so it can't reference those types.
+ // Using *anyopaque here is "dangerous", in that it requires the composer
+ // to pass the right value, which normally would be itself (`*Self`), but
+ // only because (as of now) everyting that uses KeyValueList has no prototype
+ list: *anyopaque,
+
+ pub const Entry = struct { []const u8, []const u8 };
+
+ pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry {
+ const index = self.index;
+ const entries = self.kv._entries.items;
+ if (index >= entries.len) {
+ return null;
+ }
+ self.index = index + 1;
+
+ const e = &entries[index];
+ return .{ e.name.str(), e.value.str() };
+ }
+};
+
+pub fn iterator(self: *const KeyValueList) Iterator {
+ return .{ .list = self };
+}
+
+const GenericIterator = @import("collections/iterator.zig").Entry;
+pub const KeyIterator = GenericIterator(Iterator, "0");
+pub const ValueIterator = GenericIterator(Iterator, "1");
+pub const EntryIterator = GenericIterator(Iterator, null);
diff --git a/src/browser/webapi/Location.zig b/src/browser/webapi/Location.zig
new file mode 100644
index 000000000..87d0c2827
--- /dev/null
+++ b/src/browser/webapi/Location.zig
@@ -0,0 +1,110 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const URL = @import("URL.zig");
+const Page = @import("../Page.zig");
+
+const Location = @This();
+
+_url: *URL,
+
+pub fn init(raw_url: [:0]const u8, page: *Page) !*Location {
+ const url = try URL.init(raw_url, null, page);
+ return page._factory.create(Location{
+ ._url = url,
+ });
+}
+
+pub fn getPathname(self: *const Location) []const u8 {
+ return self._url.getPathname();
+}
+
+pub fn getProtocol(self: *const Location) []const u8 {
+ return self._url.getProtocol();
+}
+
+pub fn getHostname(self: *const Location) []const u8 {
+ return self._url.getHostname();
+}
+
+pub fn getHost(self: *const Location) []const u8 {
+ return self._url.getHost();
+}
+
+pub fn getPort(self: *const Location) []const u8 {
+ return self._url.getPort();
+}
+
+pub fn getOrigin(self: *const Location, page: *const Page) ![]const u8 {
+ return self._url.getOrigin(page);
+}
+
+pub fn getSearch(self: *const Location, page: *const Page) ![]const u8 {
+ return self._url.getSearch(page);
+}
+
+pub fn getHash(self: *const Location) []const u8 {
+ return self._url.getHash();
+}
+
+pub fn setHash(_: *const Location, hash: []const u8, page: *Page) !void {
+ const normalized_hash = blk: {
+ if (hash.len == 0) {
+ const old_url = page.url;
+
+ break :blk if (std.mem.indexOfScalar(u8, old_url, '#')) |index|
+ old_url[0..index]
+ else
+ old_url;
+ } else if (hash[0] == '#')
+ break :blk hash
+ else
+ break :blk try std.fmt.allocPrint(page.arena, "#{s}", .{hash});
+ };
+
+ const duped_hash = try page.arena.dupeZ(u8, normalized_hash);
+ return page.navigate(duped_hash, .{ .reason = .script }, .{ .replace = null });
+}
+
+pub fn toString(self: *const Location, page: *const Page) ![:0]const u8 {
+ return self._url.toString(page);
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Location);
+
+ pub const Meta = struct {
+ pub const name = "Location";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const toString = bridge.function(Location.toString, .{});
+ pub const href = bridge.accessor(Location.toString, null, .{});
+ pub const search = bridge.accessor(Location.getSearch, null, .{});
+ pub const hash = bridge.accessor(Location.getHash, Location.setHash, .{});
+ pub const pathname = bridge.accessor(Location.getPathname, null, .{});
+ pub const hostname = bridge.accessor(Location.getHostname, null, .{});
+ pub const host = bridge.accessor(Location.getHost, null, .{});
+ pub const port = bridge.accessor(Location.getPort, null, .{});
+ pub const origin = bridge.accessor(Location.getOrigin, null, .{});
+ pub const protocol = bridge.accessor(Location.getProtocol, null, .{});
+};
diff --git a/src/browser/webapi/MessageChannel.zig b/src/browser/webapi/MessageChannel.zig
new file mode 100644
index 000000000..d43ba7dfc
--- /dev/null
+++ b/src/browser/webapi/MessageChannel.zig
@@ -0,0 +1,65 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const MessagePort = @import("MessagePort.zig");
+
+const MessageChannel = @This();
+
+_port1: *MessagePort,
+_port2: *MessagePort,
+
+pub fn init(page: *Page) !*MessageChannel {
+ const port1 = try MessagePort.init(page);
+ const port2 = try MessagePort.init(page);
+
+ MessagePort.entangle(port1, port2);
+
+ return page._factory.create(MessageChannel{
+ ._port1 = port1,
+ ._port2 = port2,
+ });
+}
+
+pub fn getPort1(self: *const MessageChannel) *MessagePort {
+ return self._port1;
+}
+
+pub fn getPort2(self: *const MessageChannel) *MessagePort {
+ return self._port2;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MessageChannel);
+
+ pub const Meta = struct {
+ pub const name = "MessageChannel";
+ pub var class_id: bridge.ClassId = undefined;
+ pub const prototype_chain = bridge.prototypeChain();
+ };
+
+ pub const constructor = bridge.constructor(MessageChannel.init, .{});
+ pub const port1 = bridge.accessor(MessageChannel.getPort1, null, .{});
+ pub const port2 = bridge.accessor(MessageChannel.getPort2, null, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: MessageChannel" {
+ try testing.htmlRunner("message_channel.html", .{});
+}
diff --git a/src/browser/webapi/MessagePort.zig b/src/browser/webapi/MessagePort.zig
new file mode 100644
index 000000000..4d72a9342
--- /dev/null
+++ b/src/browser/webapi/MessagePort.zig
@@ -0,0 +1,169 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const log = @import("../../log.zig");
+
+const Page = @import("../Page.zig");
+const EventTarget = @import("EventTarget.zig");
+const MessageEvent = @import("event/MessageEvent.zig");
+
+const MessagePort = @This();
+
+_proto: *EventTarget,
+_enabled: bool = false,
+_closed: bool = false,
+_on_message: ?js.Function = null,
+_on_message_error: ?js.Function = null,
+_entangled_port: ?*MessagePort = null,
+
+pub fn init(page: *Page) !*MessagePort {
+ return page._factory.eventTarget(MessagePort{
+ ._proto = undefined,
+ });
+}
+
+pub fn asEventTarget(self: *MessagePort) *EventTarget {
+ return self._proto;
+}
+
+pub fn entangle(port1: *MessagePort, port2: *MessagePort) void {
+ port1._entangled_port = port2;
+ port2._entangled_port = port1;
+}
+
+pub fn postMessage(self: *MessagePort, message: js.Object, page: *Page) !void {
+ if (self._closed) {
+ return;
+ }
+
+ const other = self._entangled_port orelse return;
+ if (other._closed) {
+ return;
+ }
+
+ // Create callback to deliver message
+ const callback = try page._factory.create(PostMessageCallback{
+ .page = page,
+ .port = other,
+ .message = try message.persist(),
+ });
+
+ try page.scheduler.add(callback, PostMessageCallback.run, 0, .{
+ .name = "MessagePort.postMessage",
+ .low_priority = false,
+ });
+}
+
+pub fn start(self: *MessagePort) void {
+ if (self._closed) {
+ return;
+ }
+ self._enabled = true;
+}
+
+pub fn close(self: *MessagePort) void {
+ self._closed = true;
+
+ // Break entanglement
+ if (self._entangled_port) |other| {
+ other._entangled_port = null;
+ }
+ self._entangled_port = null;
+}
+
+pub fn getOnMessage(self: *const MessagePort) ?js.Function {
+ return self._on_message;
+}
+
+pub fn setOnMessage(self: *MessagePort, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_message = cb;
+ } else {
+ self._on_message = null;
+ }
+}
+
+pub fn getOnMessageError(self: *const MessagePort) ?js.Function {
+ return self._on_message_error;
+}
+
+pub fn setOnMessageError(self: *MessagePort, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_message_error = cb;
+ } else {
+ self._on_message_error = null;
+ }
+}
+
+const PostMessageCallback = struct {
+ port: *MessagePort,
+ message: js.Object,
+ page: *Page,
+
+ fn deinit(self: *PostMessageCallback) void {
+ self.page._factory.destroy(self);
+ }
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
+ defer self.deinit();
+
+ if (self.port._closed) {
+ return null;
+ }
+
+ const event = MessageEvent.init("message", .{
+ .data = self.message,
+ .origin = "",
+ .source = null,
+ }, self.page) catch |err| {
+ log.err(.dom, "MessagePort.postMessage", .{ .err = err });
+ return null;
+ };
+
+ self.page._event_manager.dispatchWithFunction(
+ self.port.asEventTarget(),
+ event.asEvent(),
+ self.port._on_message,
+ .{ .context = "MessagePort message" },
+ ) catch |err| {
+ log.err(.dom, "MessagePort.postMessage", .{ .err = err });
+ };
+
+ return null;
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MessagePort);
+
+ pub const Meta = struct {
+ pub const name = "MessagePort";
+ pub var class_id: bridge.ClassId = undefined;
+ pub const prototype_chain = bridge.prototypeChain();
+ };
+
+ pub const postMessage = bridge.function(MessagePort.postMessage, .{});
+ pub const start = bridge.function(MessagePort.start, .{});
+ pub const close = bridge.function(MessagePort.close, .{});
+
+ pub const onmessage = bridge.accessor(MessagePort.getOnMessage, MessagePort.setOnMessage, .{});
+ pub const onmessageerror = bridge.accessor(MessagePort.getOnMessageError, MessagePort.setOnMessageError, .{});
+};
diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig
new file mode 100644
index 000000000..7d97a94ba
--- /dev/null
+++ b/src/browser/webapi/MutationObserver.zig
@@ -0,0 +1,338 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ MutationObserver,
+ MutationRecord,
+ };
+}
+
+const MutationObserver = @This();
+
+_callback: js.Function,
+_observing: std.ArrayList(Observing) = .{},
+_pending_records: std.ArrayList(*MutationRecord) = .{},
+
+const Observing = struct {
+ target: *Node,
+ options: ObserveOptions,
+};
+
+pub const ObserveOptions = struct {
+ attributes: bool = false,
+ attributeOldValue: bool = false,
+ childList: bool = false,
+ characterData: bool = false,
+ characterDataOldValue: bool = false,
+ subtree: bool = false,
+ attributeFilter: ?[]const []const u8 = null,
+};
+
+pub fn init(callback: js.Function, page: *Page) !*MutationObserver {
+ return page._factory.create(MutationObserver{
+ ._callback = callback,
+ });
+}
+
+pub fn observe(self: *MutationObserver, target: *Node, options: ObserveOptions, page: *Page) !void {
+ // Deep copy attributeFilter if present
+ var copied_options = options;
+ if (options.attributeFilter) |filter| {
+ const filter_copy = try page.arena.alloc([]const u8, filter.len);
+ for (filter, 0..) |name, i| {
+ filter_copy[i] = try page.arena.dupe(u8, name);
+ }
+ copied_options.attributeFilter = filter_copy;
+ }
+
+ // Check if already observing this target
+ for (self._observing.items) |*obs| {
+ if (obs.target == target) {
+ obs.options = copied_options;
+ return;
+ }
+ }
+
+ // Register with page if this is our first observation
+ if (self._observing.items.len == 0) {
+ try page.registerMutationObserver(self);
+ }
+
+ try self._observing.append(page.arena, .{
+ .target = target,
+ .options = copied_options,
+ });
+}
+
+pub fn disconnect(self: *MutationObserver, page: *Page) void {
+ page.unregisterMutationObserver(self);
+ self._observing.clearRetainingCapacity();
+ self._pending_records.clearRetainingCapacity();
+}
+
+pub fn takeRecords(self: *MutationObserver, page: *Page) ![]*MutationRecord {
+ const records = try page.call_arena.dupe(*MutationRecord, self._pending_records.items);
+ self._pending_records.clearRetainingCapacity();
+ return records;
+}
+
+// Called when an attribute changes on any element
+pub fn notifyAttributeChange(
+ self: *MutationObserver,
+ target: *Element,
+ attribute_name: []const u8,
+ old_value: ?[]const u8,
+ page: *Page,
+) !void {
+ const target_node = target.asNode();
+
+ for (self._observing.items) |obs| {
+ if (obs.target != target_node) {
+ if (!obs.options.subtree) {
+ continue;
+ }
+ if (!obs.target.contains(target_node)) {
+ continue;
+ }
+ }
+ if (!obs.options.attributes) {
+ continue;
+ }
+ if (obs.options.attributeFilter) |filter| {
+ for (filter) |name| {
+ if (std.mem.eql(u8, name, attribute_name)) {
+ break;
+ }
+ } else {
+ continue;
+ }
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .attributes,
+ ._target = target_node,
+ ._attribute_name = try page.arena.dupe(u8, attribute_name),
+ ._old_value = if (obs.options.attributeOldValue and old_value != null)
+ try page.arena.dupe(u8, old_value.?)
+ else
+ null,
+ ._added_nodes = &.{},
+ ._removed_nodes = &.{},
+ ._previous_sibling = null,
+ ._next_sibling = null,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+// Called when character data changes on a text node
+pub fn notifyCharacterDataChange(
+ self: *MutationObserver,
+ target: *Node,
+ old_value: ?[]const u8,
+ page: *Page,
+) !void {
+ for (self._observing.items) |obs| {
+ if (obs.target != target) {
+ if (!obs.options.subtree) {
+ continue;
+ }
+ if (!obs.target.contains(target)) {
+ continue;
+ }
+ }
+ if (!obs.options.characterData) {
+ continue;
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .characterData,
+ ._target = target,
+ ._attribute_name = null,
+ ._old_value = if (obs.options.characterDataOldValue and old_value != null)
+ try page.arena.dupe(u8, old_value.?)
+ else
+ null,
+ ._added_nodes = &.{},
+ ._removed_nodes = &.{},
+ ._previous_sibling = null,
+ ._next_sibling = null,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+// Called when children are added or removed from a node
+pub fn notifyChildListChange(
+ self: *MutationObserver,
+ target: *Node,
+ added_nodes: []const *Node,
+ removed_nodes: []const *Node,
+ previous_sibling: ?*Node,
+ next_sibling: ?*Node,
+ page: *Page,
+) !void {
+ for (self._observing.items) |obs| {
+ if (obs.target != target) {
+ if (!obs.options.subtree) {
+ continue;
+ }
+ if (!obs.target.contains(target)) {
+ continue;
+ }
+ }
+ if (!obs.options.childList) {
+ continue;
+ }
+
+ const record = try page._factory.create(MutationRecord{
+ ._type = .childList,
+ ._target = target,
+ ._attribute_name = null,
+ ._old_value = null,
+ ._added_nodes = try page.arena.dupe(*Node, added_nodes),
+ ._removed_nodes = try page.arena.dupe(*Node, removed_nodes),
+ ._previous_sibling = previous_sibling,
+ ._next_sibling = next_sibling,
+ });
+
+ try self._pending_records.append(page.arena, record);
+
+ try page.scheduleMutationDelivery();
+ break;
+ }
+}
+
+pub fn deliverRecords(self: *MutationObserver, page: *Page) !void {
+ if (self._pending_records.items.len == 0) {
+ return;
+ }
+
+ // Take a copy of the records and clear the list before calling callback
+ // This ensures mutations triggered during the callback go into a fresh list
+ const records = try self.takeRecords(page);
+ try self._callback.call(void, .{ records, self });
+}
+
+pub const MutationRecord = struct {
+ _type: Type,
+ _target: *Node,
+ _attribute_name: ?[]const u8,
+ _old_value: ?[]const u8,
+ _added_nodes: []const *Node,
+ _removed_nodes: []const *Node,
+ _previous_sibling: ?*Node,
+ _next_sibling: ?*Node,
+
+ pub const Type = enum {
+ attributes,
+ childList,
+ characterData,
+ };
+
+ pub fn getType(self: *const MutationRecord) []const u8 {
+ return switch (self._type) {
+ .attributes => "attributes",
+ .childList => "childList",
+ .characterData => "characterData",
+ };
+ }
+
+ pub fn getTarget(self: *const MutationRecord) *Node {
+ return self._target;
+ }
+
+ pub fn getAttributeName(self: *const MutationRecord) ?[]const u8 {
+ return self._attribute_name;
+ }
+
+ pub fn getOldValue(self: *const MutationRecord) ?[]const u8 {
+ return self._old_value;
+ }
+
+ pub fn getAddedNodes(self: *const MutationRecord) []const *Node {
+ return self._added_nodes;
+ }
+
+ pub fn getRemovedNodes(self: *const MutationRecord) []const *Node {
+ return self._removed_nodes;
+ }
+
+ pub fn getPreviousSibling(self: *const MutationRecord) ?*Node {
+ return self._previous_sibling;
+ }
+
+ pub fn getNextSibling(self: *const MutationRecord) ?*Node {
+ return self._next_sibling;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(MutationRecord);
+
+ pub const Meta = struct {
+ pub const name = "MutationRecord";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const @"type" = bridge.accessor(MutationRecord.getType, null, .{});
+ pub const target = bridge.accessor(MutationRecord.getTarget, null, .{});
+ pub const attributeName = bridge.accessor(MutationRecord.getAttributeName, null, .{});
+ pub const oldValue = bridge.accessor(MutationRecord.getOldValue, null, .{});
+ pub const addedNodes = bridge.accessor(MutationRecord.getAddedNodes, null, .{});
+ pub const removedNodes = bridge.accessor(MutationRecord.getRemovedNodes, null, .{});
+ pub const previousSibling = bridge.accessor(MutationRecord.getPreviousSibling, null, .{});
+ pub const nextSibling = bridge.accessor(MutationRecord.getNextSibling, null, .{});
+ };
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MutationObserver);
+
+ pub const Meta = struct {
+ pub const name = "MutationObserver";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(MutationObserver.init, .{});
+
+ pub const observe = bridge.function(MutationObserver.observe, .{});
+ pub const disconnect = bridge.function(MutationObserver.disconnect, .{});
+ pub const takeRecords = bridge.function(MutationObserver.takeRecords, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: MutationObserver" {
+ try testing.htmlRunner("mutation_observer", .{});
+}
diff --git a/src/browser/webapi/Navigator.zig b/src/browser/webapi/Navigator.zig
new file mode 100644
index 000000000..b38d1093f
--- /dev/null
+++ b/src/browser/webapi/Navigator.zig
@@ -0,0 +1,126 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const builtin = @import("builtin");
+const js = @import("../js/js.zig");
+
+const Navigator = @This();
+_pad: bool = false,
+
+pub const init: Navigator = .{};
+
+pub fn getUserAgent(_: *const Navigator) []const u8 {
+ return "Lightpanda/1.0";
+}
+
+pub fn getAppName(_: *const Navigator) []const u8 {
+ return "Netscape";
+}
+
+pub fn getAppCodeName(_: *const Navigator) []const u8 {
+ return "Netscape";
+}
+
+pub fn getAppVersion(_: *const Navigator) []const u8 {
+ return "1.0";
+}
+
+pub fn getPlatform(_: *const Navigator) []const u8 {
+ return switch (builtin.os.tag) {
+ .macos => "MacIntel",
+ .windows => "Win32",
+ .linux => "Linux x86_64",
+ .freebsd => "FreeBSD",
+ else => "Unknown",
+ };
+}
+
+pub fn getLanguage(_: *const Navigator) []const u8 {
+ return "en-US";
+}
+
+pub fn getLanguages(_: *const Navigator) [1][]const u8 {
+ return .{"en-US"};
+}
+
+pub fn getOnLine(_: *const Navigator) bool {
+ return true;
+}
+
+pub fn getCookieEnabled(_: *const Navigator) bool {
+ // TODO: Implement cookie support
+ return false;
+}
+
+pub fn getHardwareConcurrency(_: *const Navigator) u32 {
+ return 4;
+}
+
+pub fn getMaxTouchPoints(_: *const Navigator) u32 {
+ return 0;
+}
+
+/// Returns the vendor name
+pub fn getVendor(_: *const Navigator) []const u8 {
+ return "";
+}
+
+/// Returns the product name (typically "Gecko" for compatibility)
+pub fn getProduct(_: *const Navigator) []const u8 {
+ return "Gecko";
+}
+
+/// Returns whether Java is enabled (always false)
+pub fn javaEnabled(_: *const Navigator) bool {
+ return false;
+}
+
+/// Returns whether the browser is controlled by automation (always false)
+pub fn getWebdriver(_: *const Navigator) bool {
+ return false;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Navigator);
+
+ pub const Meta = struct {
+ pub const name = "Navigator";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ // Read-only properties
+ pub const userAgent = bridge.accessor(Navigator.getUserAgent, null, .{});
+ pub const appName = bridge.accessor(Navigator.getAppName, null, .{});
+ pub const appCodeName = bridge.accessor(Navigator.getAppCodeName, null, .{});
+ pub const appVersion = bridge.accessor(Navigator.getAppVersion, null, .{});
+ pub const platform = bridge.accessor(Navigator.getPlatform, null, .{});
+ pub const language = bridge.accessor(Navigator.getLanguage, null, .{});
+ pub const languages = bridge.accessor(Navigator.getLanguages, null, .{});
+ pub const onLine = bridge.accessor(Navigator.getOnLine, null, .{});
+ pub const cookieEnabled = bridge.accessor(Navigator.getCookieEnabled, null, .{});
+ pub const hardwareConcurrency = bridge.accessor(Navigator.getHardwareConcurrency, null, .{});
+ pub const maxTouchPoints = bridge.accessor(Navigator.getMaxTouchPoints, null, .{});
+ pub const vendor = bridge.accessor(Navigator.getVendor, null, .{});
+ pub const product = bridge.accessor(Navigator.getProduct, null, .{});
+ pub const webdriver = bridge.accessor(Navigator.getWebdriver, null, .{});
+
+ // Methods
+ pub const javaEnabled = bridge.function(Navigator.javaEnabled, .{});
+};
diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig
new file mode 100644
index 000000000..564bf5990
--- /dev/null
+++ b/src/browser/webapi/Node.zig
@@ -0,0 +1,910 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const log = @import("../../log.zig");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const reflect = @import("../reflect.zig");
+
+const EventTarget = @import("EventTarget.zig");
+const collections = @import("collections.zig");
+
+pub const CData = @import("CData.zig");
+pub const Element = @import("Element.zig");
+pub const Document = @import("Document.zig");
+pub const HTMLDocument = @import("HTMLDocument.zig");
+pub const Children = @import("children.zig").Children;
+pub const DocumentFragment = @import("DocumentFragment.zig");
+pub const DocumentType = @import("DocumentType.zig");
+pub const ShadowRoot = @import("ShadowRoot.zig");
+
+const Allocator = std.mem.Allocator;
+const LinkedList = std.DoublyLinkedList;
+
+const Node = @This();
+
+_type: Type,
+_proto: *EventTarget,
+_parent: ?*Node = null,
+_children: ?*Children = null,
+_child_link: LinkedList.Node = .{},
+
+pub const Type = union(enum) {
+ cdata: *CData,
+ element: *Element,
+ document: *Document,
+ document_type: *DocumentType,
+ attribute: *Element.Attribute,
+ document_fragment: *DocumentFragment,
+};
+
+pub fn asEventTarget(self: *Node) *EventTarget {
+ return self._proto;
+}
+
+// Returns the node as a more specific type. Will crash if node is not a `T`.
+// Use `is` to optionally get the node as T
+pub fn as(self: *Node, comptime T: type) *T {
+ return self.is(T).?;
+}
+
+// Return the node as a more specific type or `null` if the node is not a `T`.
+pub fn is(self: *Node, comptime T: type) ?*T {
+ const type_name = @typeName(T);
+ switch (self._type) {
+ .element => |el| {
+ if (T == Element) {
+ return el;
+ }
+ if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.element.")) {
+ return el.is(T);
+ }
+ },
+ .cdata => |cd| {
+ if (T == CData) {
+ return cd;
+ }
+ if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.cdata.")) {
+ return cd.is(T);
+ }
+ },
+ .attribute => |attr| {
+ if (T == Element.Attribute) {
+ return attr;
+ }
+ },
+ .document => |doc| {
+ if (T == Document) {
+ return doc;
+ }
+ if (comptime std.mem.startsWith(u8, type_name, "browser.webapi.htmldocument.")) {
+ return doc.is(T);
+ }
+ },
+ .document_type => |dt| {
+ if (T == DocumentType) {
+ return dt;
+ }
+ },
+ .document_fragment => |doc| {
+ if (T == DocumentFragment) {
+ return doc;
+ }
+ if (T == ShadowRoot) {
+ return doc.is(ShadowRoot);
+ }
+ },
+ }
+ return null;
+}
+
+/// Given a position, returns target and previous nodes required for
+/// insertAdjacentHTML, insertAdjacentElement and insertAdjacentText.
+/// * `target_node` is `*Node` (where we actually insert),
+/// * `previous_node` is `?*Node`.
+pub fn findAdjacentNodes(self: *Node, position: []const u8) !struct { *Node, ?*Node } {
+ // Prefer case-sensitive match.
+ // "beforeend" was the most common case in my tests; we might adjust the order
+ // depending on which ones websites prefer most.
+ if (std.mem.eql(u8, position, "beforeend")) {
+ return .{ self, null };
+ }
+
+ if (std.mem.eql(u8, position, "afterbegin")) {
+ // Get the first child; null indicates there are no children.
+ return .{ self, self.firstChild() };
+ }
+
+ if (std.mem.eql(u8, position, "beforebegin")) {
+ // The node must have a parent node in order to use this variant.
+ const parent_node = self.parentNode() orelse return error.NoModificationAllowed;
+ // Parent cannot be Document.
+ switch (parent_node._type) {
+ .document, .document_fragment => return error.NoModificationAllowed,
+ else => {},
+ }
+
+ return .{ parent_node, self };
+ }
+
+ if (std.mem.eql(u8, position, "afterend")) {
+ // The node must have a parent node in order to use this variant.
+ const parent_node = self.parentNode() orelse return error.NoModificationAllowed;
+ // Parent cannot be Document.
+ switch (parent_node._type) {
+ .document, .document_fragment => return error.NoModificationAllowed,
+ else => {},
+ }
+
+ // Get the next sibling or null; null indicates our node is the only one.
+ return .{ parent_node, self.nextSibling() };
+ }
+
+ // Returned if:
+ // * position is not one of the four listed values.
+ // * The input is XML that is not well-formed.
+ return error.Syntax;
+}
+
+pub fn firstChild(self: *const Node) ?*Node {
+ const children = self._children orelse return null;
+ return children.first();
+}
+
+pub fn lastChild(self: *const Node) ?*Node {
+ const children = self._children orelse return null;
+ return children.last();
+}
+
+pub fn nextSibling(self: *const Node) ?*Node {
+ return linkToNodeOrNull(self._child_link.next);
+}
+
+pub fn previousSibling(self: *const Node) ?*Node {
+ return linkToNodeOrNull(self._child_link.prev);
+}
+
+pub fn parentNode(self: *const Node) ?*Node {
+ return self._parent;
+}
+
+pub fn parentElement(self: *const Node) ?*Element {
+ const parent = self._parent orelse return null;
+ return parent.is(Element);
+}
+
+pub fn appendChild(self: *Node, child: *Node, page: *Page) !*Node {
+ if (child.is(DocumentFragment)) |_| {
+ try page.appendAllChildren(child, self);
+ return child;
+ }
+
+ page.domChanged();
+
+ // If the child is currently connected, and if its new parent is connected,
+ // then we can remove + add a bit more efficiently (we don't have to fully
+ // disconnect then reconnect)
+ const child_connected = child.isConnected();
+
+ if (child._parent) |parent| {
+ // we can signal removeNode that the child will remain connected
+ // (when it's appended to self) so that it can be a bit more efficient.
+ page.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() });
+ }
+
+ try page.appendNode(self, child, .{ .child_already_connected = child_connected });
+ return child;
+}
+
+pub fn childNodes(self: *const Node, page: *Page) !*collections.ChildNodes {
+ return collections.ChildNodes.init(self._children, page);
+}
+
+pub fn getTextContent(self: *Node, writer: *std.Io.Writer) error{WriteFailed}!void {
+ switch (self._type) {
+ .element => {
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ // ignore comments and TODO processing instructions.
+ if (child.is(CData.Comment) != null) {
+ continue;
+ }
+ try child.getTextContent(writer);
+ }
+ },
+ .cdata => |c| try writer.writeAll(c.getData()),
+ .document => {},
+ .document_type => {},
+ .document_fragment => {},
+ .attribute => |attr| try writer.writeAll(attr._value),
+ }
+}
+
+pub fn getTextContentAlloc(self: *Node, allocator: Allocator) error{WriteFailed}![:0]const u8 {
+ var buf = std.Io.Writer.Allocating.init(allocator);
+ try self.getTextContent(&buf.writer);
+ try buf.writer.writeByte(0);
+ const data = buf.written();
+ return data[0 .. data.len - 1 :0];
+}
+
+pub fn setTextContent(self: *Node, data: []const u8, page: *Page) !void {
+ switch (self._type) {
+ .element => |el| return el.replaceChildren(&.{.{ .text = data }}, page),
+ .cdata => |c| c._data = try page.arena.dupe(u8, data),
+ .document => {},
+ .document_type => {},
+ .document_fragment => |frag| return frag.replaceChildren(&.{.{ .text = data }}, page),
+ .attribute => |attr| return attr.setValue(data, page),
+ }
+}
+
+pub fn getNodeName(self: *const Node, buf: []u8) []const u8 {
+ return switch (self._type) {
+ .element => |el| el.getTagNameSpec(buf),
+ .cdata => |cd| switch (cd._type) {
+ .text => "#text",
+ .cdata_section => "#cdata-section",
+ .comment => "#comment",
+ },
+ .document => "#document",
+ .document_type => |dt| dt.getName(),
+ .document_fragment => "#document-fragment",
+ .attribute => |attr| attr._name,
+ };
+}
+
+pub fn getNodeType(self: *const Node) u8 {
+ return switch (self._type) {
+ .element => 1,
+ .attribute => 2,
+ .cdata => |cd| switch (cd._type) {
+ .text => 3,
+ .cdata_section => 4,
+ .comment => 8,
+ },
+ .document => 9,
+ .document_type => 10,
+ .document_fragment => 11,
+ };
+}
+
+pub fn isEqualNode(self: *Node, other: *Node) bool {
+ // Make sure types match.
+ if (self.getNodeType() != other.getNodeType()) {
+ return false;
+ }
+
+ // TODO: Compare `localName` and prefix.
+ return switch (self._type) {
+ .element => self.as(Element).isEqualNode(other.as(Element)),
+ .attribute => self.as(Element.Attribute).isEqualNode(other.as(Element.Attribute)),
+ .cdata => self.as(CData).isEqualNode(other.as(CData)),
+ else => {
+ log.warn(.browser, "not implemented", .{
+ .type = self._type,
+ .feature = "Node.isEqualNode",
+ });
+ return false;
+ },
+ };
+}
+
+pub fn isInShadowTree(self: *Node) bool {
+ var node = self._parent;
+ while (node) |n| {
+ if (n.is(ShadowRoot) != null) {
+ return true;
+ }
+ node = n._parent;
+ }
+ return false;
+}
+
+pub fn isConnected(self: *const Node) bool {
+ const target = Page.current.document.asNode();
+ if (self == target) {
+ return true;
+ }
+
+ var node = self._parent;
+ while (node) |n| {
+ if (n == target) {
+ return true;
+ }
+ node = n._parent;
+ }
+ return false;
+}
+
+const GetRootNodeOpts = struct {
+ composed: bool = false,
+};
+pub fn getRootNode(self: *const Node, opts_: ?GetRootNodeOpts) *const Node {
+ const opts = opts_ orelse GetRootNodeOpts{};
+
+ var root = self;
+ while (root._parent) |parent| {
+ root = parent;
+ }
+
+ // If composed is true, traverse through shadow boundaries
+ if (opts.composed) {
+ while (true) {
+ const shadow_root = @constCast(root).is(ShadowRoot) orelse break;
+ root = shadow_root.getHost().asNode();
+ while (root._parent) |parent| {
+ root = parent;
+ }
+ }
+ }
+
+ return root;
+}
+
+pub fn contains(self: *const Node, child: *const Node) bool {
+ if (self == child) {
+ // yes, this is correct
+ return true;
+ }
+
+ var parent = child._parent;
+ while (parent) |p| {
+ if (p == self) {
+ return true;
+ }
+ parent = p._parent;
+ }
+ return false;
+}
+
+pub fn ownerDocument(self: *const Node, page: *const Page) ?*Document {
+ // A document node does not have an owner.
+ if (self._type == .document) {
+ return null;
+ }
+
+ // The root of the tree that a node belongs to is its owner.
+ var current = self;
+ while (current._parent) |parent| {
+ current = parent;
+ }
+
+ // If the root is a document, then that's our owner.
+ if (current._type == .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.
+ return page.document;
+}
+
+pub fn hasChildNodes(self: *const Node) bool {
+ return self.firstChild() != null;
+}
+
+pub fn isSameNode(self: *const Node, other: ?*Node) bool {
+ return self == other;
+}
+
+pub fn removeChild(self: *Node, child: *Node, page: *Page) !*Node {
+ var it = self.childrenIterator();
+ while (it.next()) |n| {
+ if (n == child) {
+ page.domChanged();
+ page.removeNode(self, child, .{ .will_be_reconnected = false });
+ return child;
+ }
+ }
+ return error.NotFound;
+}
+
+pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, page: *Page) !*Node {
+ const ref_node = ref_node_ orelse {
+ return self.appendChild(new_node, page);
+ };
+
+ if (ref_node._parent == null or ref_node._parent.? != self) {
+ return error.NotFound;
+ }
+
+ if (new_node.is(DocumentFragment)) |_| {
+ try page.insertAllChildrenBefore(new_node, self, ref_node);
+ return new_node;
+ }
+
+ const child_already_connected = new_node.isConnected();
+
+ page.domChanged();
+ const will_be_reconnected = self.isConnected();
+ if (new_node._parent) |parent| {
+ page.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
+ }
+
+ try page.insertNodeRelative(
+ self,
+ new_node,
+ .{ .before = ref_node },
+ .{ .child_already_connected = child_already_connected },
+ );
+
+ return new_node;
+}
+
+pub fn replaceChild(self: *Node, new_child: *Node, old_child: *Node, page: *Page) !*Node {
+ if (old_child._parent == null or old_child._parent.? != self) {
+ return error.HierarchyError;
+ }
+ if (self._type != .document and self._type != .element) {
+ return error.HierarchyError;
+ }
+ if (new_child.contains(self)) {
+ return error.HierarchyError;
+ }
+
+ _ = try self.insertBefore(new_child, old_child, page);
+ page.removeNode(self, old_child, .{ .will_be_reconnected = false });
+ return old_child;
+}
+
+pub fn getNodeValue(self: *const Node) ?[]const u8 {
+ return switch (self._type) {
+ .cdata => |c| c.getData(),
+ .attribute => |attr| attr._value,
+ .element => null,
+ .document => null,
+ .document_type => null,
+ .document_fragment => null,
+ };
+}
+
+pub fn setNodeValue(self: *const Node, value: ?[]const u8, page: *Page) !void {
+ switch (self._type) {
+ .cdata => |c| try c.setData(value, page),
+ .attribute => |attr| try attr.setValue(value, page),
+ .element => {},
+ .document => {},
+ .document_type => {},
+ .document_fragment => {},
+ }
+}
+
+pub fn format(self: *Node, writer: *std.Io.Writer) !void {
+ // // If you need extra debugging:
+ // return @import("../dump.zig").deep(self, .{}, writer);
+ return switch (self._type) {
+ .cdata => |cd| cd.format(writer),
+ .element => |el| writer.print("{f}", .{el}),
+ .document => writer.writeAll(""),
+ .document_type => writer.writeAll(""),
+ .document_fragment => writer.writeAll(""),
+ .attribute => |attr| writer.print("{f}", .{attr}),
+ };
+}
+
+// Returns an iterator the can be used to iterate through the node's children
+// For internal use.
+pub fn childrenIterator(self: *Node) NodeIterator {
+ const children = self._children orelse {
+ return .{ .node = null };
+ };
+
+ return .{
+ .node = children.first(),
+ };
+}
+
+pub fn getChildrenCount(self: *Node) usize {
+ return switch (self._type) {
+ .element, .document, .document_fragment => self.getLength(),
+ .document_type, .attribute, .cdata => return 0,
+ };
+}
+
+pub fn getLength(self: *Node) u32 {
+ switch (self._type) {
+ .cdata => |cdata| {
+ return @intCast(cdata.getData().len);
+ },
+ .element, .document, .document_fragment => {
+ var count: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |_| {
+ count += 1;
+ }
+ return count;
+ },
+ .document_type, .attribute => return 0,
+ }
+}
+
+pub fn getChildIndex(self: *Node, target: *const Node) ?u32 {
+ var i: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ if (child == target) {
+ return i;
+ }
+ i += 1;
+ }
+ return null;
+}
+
+pub fn getChildAt(self: *Node, index: u32) ?*Node {
+ var i: u32 = 0;
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ if (i == index) {
+ return child;
+ }
+ i += 1;
+ }
+ return null;
+}
+
+pub fn getData(self: *const Node) []const u8 {
+ return switch (self._type) {
+ .cdata => |c| c.getData(),
+ else => "",
+ };
+}
+
+pub fn setData(self: *Node, data: []const u8) void {
+ switch (self._type) {
+ .cdata => |c| c._data = data,
+ else => {},
+ }
+}
+
+pub fn className(self: *const Node) []const u8 {
+ switch (self._type) {
+ inline else => |c| return c.className(),
+ }
+}
+
+pub fn normalize(self: *Node, page: *Page) !void {
+ var buffer: std.ArrayListUnmanaged(u8) = .empty;
+ return self._normalize(page.call_arena, &buffer, page);
+}
+
+pub fn cloneNode(self: *Node, deep_: ?bool, page: *Page) error{ OutOfMemory, StringTooLarge, NotSupported, NotImplemented, InvalidCharacterError }!*Node {
+ const deep = deep_ orelse false;
+ switch (self._type) {
+ .cdata => |cd| {
+ const data = cd.getData();
+ return switch (cd._type) {
+ .text => page.createTextNode(data),
+ .cdata_section => page.createCDATASection(data),
+ .comment => page.createComment(data),
+ };
+ },
+ .element => |el| return el.cloneElement(deep, page),
+ .document => return error.NotSupported,
+ .document_type => return error.NotSupported,
+ .document_fragment => |frag| return frag.cloneFragment(deep, page),
+ .attribute => return error.NotSupported,
+ }
+}
+
+pub fn compareDocumentPosition(self: *const Node, other: *const Node) u16 {
+ const DISCONNECTED: u16 = 0x01;
+ const PRECEDING: u16 = 0x02;
+ const FOLLOWING: u16 = 0x04;
+ const CONTAINS: u16 = 0x08;
+ const CONTAINED_BY: u16 = 0x10;
+ const IMPLEMENTATION_SPECIFIC: u16 = 0x20;
+
+ if (self == other) {
+ return 0;
+ }
+
+ // Check if either node is disconnected
+ const self_root = self.getRootNode(.{});
+ const other_root = other.getRootNode(.{});
+
+ if (self_root != other_root) {
+ // Nodes are in different trees - disconnected
+ // Use pointer comparison for implementation-specific ordering
+ return DISCONNECTED | IMPLEMENTATION_SPECIFIC | if (@intFromPtr(self) < @intFromPtr(other)) FOLLOWING else PRECEDING;
+ }
+
+ // Check if one contains the other
+ if (self.contains(other)) {
+ return FOLLOWING | CONTAINED_BY;
+ }
+
+ if (other.contains(self)) {
+ return PRECEDING | CONTAINS;
+ }
+
+ // Neither contains the other - find common ancestor and compare positions
+ // Walk up from self to build ancestor chain
+ var self_ancestors: [256]*const Node = undefined;
+ var ancestor_count: usize = 0;
+ var current: ?*const Node = self;
+ while (current) |node| : (current = node._parent) {
+ if (ancestor_count >= self_ancestors.len) break;
+ self_ancestors[ancestor_count] = node;
+ ancestor_count += 1;
+ }
+
+ const ancestors = self_ancestors[0..ancestor_count];
+
+ // Walk up from other until we find common ancestor
+ current = other;
+ while (current) |node| : (current = node._parent) {
+ // Check if this node is in self's ancestor chain
+ for (ancestors, 0..) |ancestor, i| {
+ if (ancestor != node) {
+ continue;
+ }
+
+ // Found common ancestor
+ // Compare the children that are ancestors of self and other
+ if (i == 0) {
+ // self is directly under the common ancestor
+ // Find other's ancestor that's a child of the common ancestor
+ if (other == node) {
+ // other is the common ancestor, so self follows it
+ return FOLLOWING;
+ }
+ var other_ancestor = other;
+ while (other_ancestor._parent) |p| {
+ if (p == node) break;
+ other_ancestor = p;
+ }
+ return if (isNodeBefore(self, other_ancestor)) FOLLOWING else PRECEDING;
+ }
+
+ const self_ancestor = self_ancestors[i - 1];
+ // Find other's ancestor that's a child of the common ancestor
+ var other_ancestor = other;
+ if (other == node) {
+ // other is the common ancestor, so self is contained by it
+ return PRECEDING | CONTAINS;
+ }
+ while (other_ancestor._parent) |p| {
+ if (p == node) break;
+ other_ancestor = p;
+ }
+ return if (isNodeBefore(self_ancestor, other_ancestor)) FOLLOWING else PRECEDING;
+ }
+ }
+
+ // Shouldn't reach here if both nodes are in the same tree
+ return DISCONNECTED;
+}
+
+// faster to compare the linked list node links directly
+fn isNodeBefore(node1: *const Node, node2: *const Node) bool {
+ var current = node1._child_link.next;
+ const target = &node2._child_link;
+ while (current) |link| {
+ if (link == target) return true;
+ current = link.next;
+ }
+ return false;
+}
+
+fn _normalize(self: *Node, allocator: Allocator, buffer: *std.ArrayListUnmanaged(u8), page: *Page) !void {
+ var it = self.childrenIterator();
+ while (it.next()) |child| {
+ try child._normalize(allocator, buffer, page);
+ }
+
+ var child = self.firstChild();
+ while (child) |current_node| {
+ var next_node = current_node.nextSibling();
+
+ const text_node = current_node.is(CData.Text) orelse {
+ child = next_node;
+ continue;
+ };
+
+ if (text_node._proto.getData().len == 0) {
+ page.removeNode(self, current_node, .{ .will_be_reconnected = false });
+ child = next_node;
+ continue;
+ }
+
+ if (next_node) |next| {
+ if (next.is(CData.Text)) |_| {
+ try buffer.appendSlice(allocator, text_node.getWholeText());
+
+ while (next_node) |node_to_merge| {
+ const next_text_node = node_to_merge.is(CData.Text) orelse break;
+ try buffer.appendSlice(allocator, next_text_node.getWholeText());
+
+ const to_remove = node_to_merge;
+ next_node = node_to_merge.nextSibling();
+ page.removeNode(self, to_remove, .{ .will_be_reconnected = false });
+ }
+ text_node._proto._data = try page.dupeString(buffer.items);
+ buffer.clearRetainingCapacity();
+ }
+ }
+
+ child = next_node;
+ }
+}
+
+// Writes a JSON representation of the node and its children
+pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void {
+ // stupid json api requires this to be const,
+ // so we @constCast it because our stringify re-uses code that can be
+ // used to iterate nodes, e.g. the NodeIterator
+ return @import("../dump.zig").toJSON(@constCast(self), writer);
+}
+
+const NodeIterator = struct {
+ node: ?*Node,
+ pub fn next(self: *NodeIterator) ?*Node {
+ const node = self.node orelse return null;
+ self.node = linkToNodeOrNull(node._child_link.next);
+ return node;
+ }
+};
+
+// Turns a linked list node into a Node
+pub fn linkToNode(n: *LinkedList.Node) *Node {
+ return @fieldParentPtr("_child_link", n);
+}
+
+pub fn linkToNodeOrNull(n_: ?*LinkedList.Node) ?*Node {
+ return if (n_) |n| linkToNode(n) else null;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Node);
+
+ pub const Meta = struct {
+ pub const name = "Node";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const ELEMENT_NODE = bridge.property(1);
+ pub const ATTRIBUTE_NODE = bridge.property(2);
+ pub const TEXT_NODE = bridge.property(3);
+ pub const CDATA_SECTION_NODE = bridge.property(4);
+ 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 DOCUMENT_POSITION_DISCONNECTED = bridge.property(0x01);
+ pub const DOCUMENT_POSITION_PRECEDING = bridge.property(0x02);
+ pub const DOCUMENT_POSITION_FOLLOWING = bridge.property(0x04);
+ pub const DOCUMENT_POSITION_CONTAINS = bridge.property(0x08);
+ pub const DOCUMENT_POSITION_CONTAINED_BY = bridge.property(0x10);
+ pub const DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = bridge.property(0x20);
+
+ pub const nodeName = bridge.accessor(struct {
+ fn wrap(self: *const Node, page: *Page) []const u8 {
+ return self.getNodeName(&page.buf);
+ }
+ }.wrap, null, .{});
+ pub const nodeType = bridge.accessor(Node.getNodeType, null, .{});
+
+ pub const textContent = bridge.accessor(_textContext, Node.setTextContent, .{});
+ fn _textContext(self: *Node, page: *const Page) !?[]const u8 {
+ // cdata and attributes can return value directly, avoiding the copy
+ switch (self._type) {
+ .element => |el| {
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try el.asNode().getTextContent(&buf.writer);
+ return buf.written();
+ },
+ .cdata => |cdata| return cdata.getData(),
+ .attribute => |attr| return attr._value,
+ .document => return null,
+ .document_type => return null,
+ .document_fragment => return null,
+ }
+ }
+
+ pub const firstChild = bridge.accessor(Node.firstChild, null, .{});
+ pub const lastChild = bridge.accessor(Node.lastChild, null, .{});
+ pub const nextSibling = bridge.accessor(Node.nextSibling, null, .{});
+ pub const previousSibling = bridge.accessor(Node.previousSibling, null, .{});
+ pub const parentNode = bridge.accessor(Node.parentNode, null, .{});
+ pub const parentElement = bridge.accessor(Node.parentElement, null, .{});
+ pub const appendChild = bridge.function(Node.appendChild, .{});
+ pub const childNodes = bridge.accessor(Node.childNodes, null, .{});
+ pub const isConnected = bridge.accessor(Node.isConnected, null, .{});
+ pub const ownerDocument = bridge.accessor(Node.ownerDocument, null, .{});
+ pub const hasChildNodes = bridge.function(Node.hasChildNodes, .{});
+ pub const isSameNode = bridge.function(Node.isSameNode, .{});
+ pub const contains = bridge.function(Node.contains, .{});
+ pub const removeChild = bridge.function(Node.removeChild, .{ .dom_exception = true });
+ pub const nodeValue = bridge.accessor(Node.getNodeValue, Node.setNodeValue, .{});
+ pub const insertBefore = bridge.function(Node.insertBefore, .{ .dom_exception = true });
+ pub const replaceChild = bridge.function(Node.replaceChild, .{ .dom_exception = true });
+ pub const normalize = bridge.function(Node.normalize, .{});
+ pub const cloneNode = bridge.function(Node.cloneNode, .{ .dom_exception = true });
+ pub const compareDocumentPosition = bridge.function(Node.compareDocumentPosition, .{});
+ 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();
+ }
+};
+
+pub const Build = struct {
+ // Calls `func_name` with `args` on the most specific type where it is
+ // implement. This could be on the Node itself (as a last-resort);
+ pub fn call(self: *const Node, comptime func_name: []const u8, args: anytype) !void {
+ inline for (@typeInfo(Node.Type).@"union".fields) |f| {
+ // The inner type has its own "call" method. Defer to it.
+ if (@field(Node.Type, f.name) == self._type) {
+ const S = reflect.Struct(f.type);
+ if (@hasDecl(S, "Build")) {
+ if (@hasDecl(S.Build, "call")) {
+ const sub = @field(self._type, f.name);
+ if (try S.Build.call(sub, func_name, args)) {
+ return;
+ }
+ }
+ // The inner type implements this function. Call it and we're done.
+ if (@hasDecl(S, func_name)) {
+ return @call(.auto, @field(f.type, func_name), args);
+ }
+ }
+ }
+ }
+
+ if (@hasDecl(Node.Build, func_name)) {
+ // Our last resort - the node implements this function.
+ return @call(.auto, @field(Node.Build, func_name), args);
+ }
+ }
+};
+
+pub const NodeOrText = union(enum) {
+ node: *Node,
+ text: []const u8,
+
+ pub fn format(self: *const NodeOrText, writer: *std.io.Writer) !void {
+ switch (self.*) {
+ .node => |n| try n.format(writer),
+ .text => |text| {
+ try writer.writeByte('\'');
+ try writer.writeAll(text);
+ try writer.writeByte('\'');
+ },
+ }
+ }
+
+ pub fn toNode(self: *const NodeOrText, page: *Page) !*Node {
+ return switch (self.*) {
+ .node => |n| n,
+ .text => |txt| page.createTextNode(txt),
+ };
+ }
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Node" {
+ try testing.htmlRunner("node", .{});
+}
diff --git a/src/browser/webapi/NodeFilter.zig b/src/browser/webapi/NodeFilter.zig
new file mode 100644
index 000000000..bdb523a80
--- /dev/null
+++ b/src/browser/webapi/NodeFilter.zig
@@ -0,0 +1,108 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+
+const NodeFilter = @This();
+
+_func: ?js.Function,
+_original_filter: ?FilterOpts,
+
+pub const FilterOpts = union(enum) {
+ function: js.Function,
+ object: struct {
+ pub const js_as_object = true;
+ acceptNode: js.Function,
+ },
+};
+
+pub fn init(opts_: ?FilterOpts) !NodeFilter {
+ const opts = opts_ orelse return .{ ._func = null, ._original_filter = null };
+ const func = switch (opts) {
+ .function => |func| func,
+ .object => |obj| obj.acceptNode,
+ };
+ return .{ ._func = func, ._original_filter = opts_ };
+}
+
+// Constants
+pub const FILTER_ACCEPT: i32 = 1;
+pub const FILTER_REJECT: i32 = 2;
+pub const FILTER_SKIP: i32 = 3;
+
+// whatToShow constants
+pub const SHOW_ALL: u32 = 0xFFFFFFFF;
+pub const SHOW_ELEMENT: u32 = 0x1;
+pub const SHOW_ATTRIBUTE: u32 = 0x2;
+pub const SHOW_TEXT: u32 = 0x4;
+pub const SHOW_CDATA_SECTION: u32 = 0x8;
+pub const SHOW_ENTITY_REFERENCE: u32 = 0x10;
+pub const SHOW_ENTITY: u32 = 0x20;
+pub const SHOW_PROCESSING_INSTRUCTION: u32 = 0x40;
+pub const SHOW_COMMENT: u32 = 0x80;
+pub const SHOW_DOCUMENT: u32 = 0x100;
+pub const SHOW_DOCUMENT_TYPE: u32 = 0x200;
+pub const SHOW_DOCUMENT_FRAGMENT: u32 = 0x400;
+pub const SHOW_NOTATION: u32 = 0x800;
+
+pub fn acceptNode(self: *const NodeFilter, node: *Node) !i32 {
+ const func = self._func orelse return FILTER_ACCEPT;
+ return func.call(i32, .{node});
+}
+
+pub fn shouldShow(node: *const Node, what_to_show: u32) bool {
+ // TODO: Test this mapping thoroughly!
+ // nodeType values (1=ELEMENT, 3=TEXT, 9=DOCUMENT, etc.) need to map to
+ // SHOW_* bitmask positions (0x1, 0x4, 0x100, etc.)
+ const node_type_value = node.getNodeType();
+ const bit_position = node_type_value - 1;
+ const node_type_bit: u32 = @as(u32, 1) << @intCast(bit_position);
+ return (what_to_show & node_type_bit) != 0;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(NodeFilter);
+
+ pub const Meta = struct {
+ pub const name = "NodeFilter";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT);
+ pub const FILTER_REJECT = bridge.property(NodeFilter.FILTER_REJECT);
+ pub const FILTER_SKIP = bridge.property(NodeFilter.FILTER_SKIP);
+
+ pub const SHOW_ALL = bridge.property(NodeFilter.SHOW_ALL);
+ pub const SHOW_ELEMENT = bridge.property(NodeFilter.SHOW_ELEMENT);
+ pub const SHOW_ATTRIBUTE = bridge.property(NodeFilter.SHOW_ATTRIBUTE);
+ pub const SHOW_TEXT = bridge.property(NodeFilter.SHOW_TEXT);
+ pub const SHOW_CDATA_SECTION = bridge.property(NodeFilter.SHOW_CDATA_SECTION);
+ pub const SHOW_ENTITY_REFERENCE = bridge.property(NodeFilter.SHOW_ENTITY_REFERENCE);
+ pub const SHOW_ENTITY = bridge.property(NodeFilter.SHOW_ENTITY);
+ pub const SHOW_PROCESSING_INSTRUCTION = bridge.property(NodeFilter.SHOW_PROCESSING_INSTRUCTION);
+ pub const SHOW_COMMENT = bridge.property(NodeFilter.SHOW_COMMENT);
+ pub const SHOW_DOCUMENT = bridge.property(NodeFilter.SHOW_DOCUMENT);
+ pub const SHOW_DOCUMENT_TYPE = bridge.property(NodeFilter.SHOW_DOCUMENT_TYPE);
+ pub const SHOW_DOCUMENT_FRAGMENT = bridge.property(NodeFilter.SHOW_DOCUMENT_FRAGMENT);
+ pub const SHOW_NOTATION = bridge.property(NodeFilter.SHOW_NOTATION);
+};
diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig
new file mode 100644
index 000000000..5253373d2
--- /dev/null
+++ b/src/browser/webapi/Performance.zig
@@ -0,0 +1,400 @@
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const datetime = @import("../../datetime.zig");
+
+pub fn registerTypes() []const type {
+ return &.{ Performance, Entry, Mark, Measure };
+}
+
+const std = @import("std");
+
+const Performance = @This();
+
+_time_origin: u64,
+_entries: std.ArrayListUnmanaged(*Entry) = .{},
+
+/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
+/// to match browser behavior (prevents fingerprinting)
+fn highResTimestamp() u64 {
+ const ts = datetime.timespec();
+ const micros = @as(u64, @intCast(ts.sec)) * 1_000_000 + @as(u64, @intCast(@divTrunc(ts.nsec, 1_000)));
+ // Round to nearest 5 microseconds (like Firefox default)
+ const rounded = @divTrunc(micros + 2, 5) * 5;
+ return rounded;
+}
+
+pub fn init() Performance {
+ return .{
+ ._time_origin = highResTimestamp(),
+ ._entries = .{},
+ };
+}
+
+pub fn now(self: *const Performance) f64 {
+ const current = highResTimestamp();
+ const elapsed = current - self._time_origin;
+ // Return as milliseconds with microsecond precision
+ return @as(f64, @floatFromInt(elapsed)) / 1000.0;
+}
+
+pub fn getTimeOrigin(self: *const Performance) f64 {
+ // Return as milliseconds
+ return @as(f64, @floatFromInt(self._time_origin)) / 1000.0;
+}
+
+pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page) !*Mark {
+ const m = try Mark.init(name, _options, page);
+ try self._entries.append(page.arena, m._proto);
+ return m;
+}
+
+const MeasureOptionsOrStartMark = union(enum) {
+ measure_options: Measure.Options,
+ start_mark: []const u8,
+};
+
+pub fn measure(
+ self: *Performance,
+ name: []const u8,
+ maybe_options_or_start: ?MeasureOptionsOrStartMark,
+ maybe_end_mark: ?[]const u8,
+ page: *Page,
+) !*Measure {
+ if (maybe_options_or_start) |options_or_start| switch (options_or_start) {
+ .measure_options => |options| {
+ // Get start timestamp.
+ const start_timestamp = blk: {
+ if (options.start) |timestamp_or_mark| {
+ break :blk switch (timestamp_or_mark) {
+ .timestamp => |timestamp| timestamp,
+ .mark => |mark_name| try self.getMarkTime(mark_name),
+ };
+ }
+
+ break :blk 0.0;
+ };
+
+ // Get end timestamp.
+ const end_timestamp = blk: {
+ if (options.end) |timestamp_or_mark| {
+ break :blk switch (timestamp_or_mark) {
+ .timestamp => |timestamp| timestamp,
+ .mark => |mark_name| try self.getMarkTime(mark_name),
+ };
+ }
+
+ break :blk self.now();
+ };
+
+ const m = try Measure.init(
+ name,
+ options.detail,
+ start_timestamp,
+ end_timestamp,
+ options.duration,
+ page,
+ );
+ try self._entries.append(page.arena, m._proto);
+ return m;
+ },
+ .start_mark => |start_mark| {
+ // Get start timestamp.
+ const start_timestamp = try self.getMarkTime(start_mark);
+ // Get end timestamp.
+ const end_timestamp = blk: {
+ if (maybe_end_mark) |mark_name| {
+ break :blk try self.getMarkTime(mark_name);
+ }
+
+ break :blk self.now();
+ };
+
+ const m = try Measure.init(
+ name,
+ null,
+ start_timestamp,
+ end_timestamp,
+ null,
+ page,
+ );
+ try self._entries.append(page.arena, m._proto);
+ return m;
+ },
+ };
+
+ const m = try Measure.init(name, null, 0.0, self.now(), null, page);
+ try self._entries.append(page.arena, m._proto);
+ return m;
+}
+
+pub fn clearMarks(self: *Performance, mark_name: ?[]const u8) void {
+ var i: usize = 0;
+ while (i < self._entries.items.len) {
+ const entry = self._entries.items[i];
+ if (entry._type == .mark and (mark_name == null or std.mem.eql(u8, entry._name, mark_name.?))) {
+ _ = self._entries.orderedRemove(i);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+pub fn clearMeasures(self: *Performance, measure_name: ?[]const u8) void {
+ var i: usize = 0;
+ while (i < self._entries.items.len) {
+ const entry = self._entries.items[i];
+ if (entry._type == .measure and (measure_name == null or std.mem.eql(u8, entry._name, measure_name.?))) {
+ _ = self._entries.orderedRemove(i);
+ } else {
+ i += 1;
+ }
+ }
+}
+
+pub fn getEntries(self: *const Performance) []*Entry {
+ return self._entries.items;
+}
+
+pub fn getEntriesByType(self: *const Performance, entry_type: []const u8, page: *Page) ![]const *Entry {
+ var result: std.ArrayList(*Entry) = .empty;
+
+ for (self._entries.items) |entry| {
+ if (std.mem.eql(u8, entry.getEntryType(), entry_type)) {
+ try result.append(page.call_arena, entry);
+ }
+ }
+
+ return result.items;
+}
+
+pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: ?[]const u8, page: *Page) ![]const *Entry {
+ var result: std.ArrayList(*Entry) = .empty;
+
+ for (self._entries.items) |entry| {
+ if (!std.mem.eql(u8, entry._name, name)) {
+ continue;
+ }
+
+ const et = entry_type orelse {
+ try result.append(page.call_arena, entry);
+ continue;
+ };
+
+ if (std.mem.eql(u8, entry.getEntryType(), et)) {
+ try result.append(page.call_arena, entry);
+ }
+ }
+
+ return result.items;
+}
+
+fn getMarkTime(self: *const Performance, mark_name: []const u8) !f64 {
+ for (self._entries.items) |entry| {
+ if (entry._type == .mark and std.mem.eql(u8, entry._name, mark_name)) {
+ return entry._start_time;
+ }
+ }
+
+ // Recognized mark names by browsers. `navigationStart` is an equivalent
+ // to 0. Others are dependant to request arrival, end of request etc.
+ if (std.mem.eql(u8, "navigationStart", mark_name)) {
+ return 0;
+ }
+
+ return error.SyntaxError; // Mark not found
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Performance);
+
+ pub const Meta = struct {
+ pub const name = "Performance";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const now = bridge.function(Performance.now, .{});
+ pub const mark = bridge.function(Performance.mark, .{});
+ pub const measure = bridge.function(Performance.measure, .{});
+ pub const clearMarks = bridge.function(Performance.clearMarks, .{});
+ pub const clearMeasures = bridge.function(Performance.clearMeasures, .{});
+ pub const getEntries = bridge.function(Performance.getEntries, .{});
+ pub const getEntriesByType = bridge.function(Performance.getEntriesByType, .{});
+ pub const getEntriesByName = bridge.function(Performance.getEntriesByName, .{});
+ pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{});
+};
+
+pub const Entry = struct {
+ _duration: f64 = 0.0,
+ _type: Type,
+ _name: []const u8,
+ _start_time: f64 = 0.0,
+
+ const Type = union(enum) {
+ element,
+ event,
+ first_input,
+ largest_contentful_paint,
+ layout_shift,
+ long_animation_frame,
+ longtask,
+ measure: *Measure,
+ navigation,
+ paint,
+ resource,
+ taskattribution,
+ visibility_state,
+ mark: *Mark,
+ };
+
+ pub fn getDuration(self: *const Entry) f64 {
+ return self._duration;
+ }
+
+ pub fn getEntryType(self: *const Entry) []const u8 {
+ return switch (self._type) {
+ .first_input => "first-input",
+ .largest_contentful_paint => "largest-contentful-paint",
+ .layout_shift => "layout-shift",
+ .long_animation_frame => "long-animation-frame",
+ .visibility_state => "visibility-state",
+ else => |t| @tagName(t),
+ };
+ }
+
+ pub fn getName(self: *const Entry) []const u8 {
+ return self._name;
+ }
+
+ pub fn getStartTime(self: *const Entry) f64 {
+ return self._start_time;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(Entry);
+
+ pub const Meta = struct {
+ pub const name = "PerformanceEntry";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+ pub const name = bridge.accessor(Entry.getName, null, .{});
+ pub const duration = bridge.accessor(Entry.getDuration, null, .{});
+ pub const entryType = bridge.accessor(Entry.getEntryType, null, .{});
+ pub const startTime = bridge.accessor(Entry.getStartTime, null, .{});
+ };
+};
+
+pub const Mark = struct {
+ _proto: *Entry,
+ _detail: ?js.Object,
+
+ const Options = struct {
+ detail: ?js.Object = null,
+ startTime: ?f64 = null,
+ };
+
+ pub fn init(name: []const u8, _opts: ?Options, page: *Page) !*Mark {
+ const opts = _opts orelse Options{};
+ const start_time = opts.startTime orelse page.window._performance.now();
+
+ if (start_time < 0.0) {
+ return error.TypeError;
+ }
+
+ const detail = if (opts.detail) |d| try d.persist() else null;
+ const m = try page._factory.create(Mark{
+ ._proto = undefined,
+ ._detail = detail,
+ });
+
+ const entry = try page._factory.create(Entry{
+ ._start_time = start_time,
+ ._name = try page.dupeString(name),
+ ._type = .{ .mark = m },
+ });
+ m._proto = entry;
+ return m;
+ }
+
+ pub fn getDetail(self: *const Mark) ?js.Object {
+ return self._detail;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(Mark);
+
+ pub const Meta = struct {
+ pub const name = "PerformanceMark";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+ pub const detail = bridge.accessor(Mark.getDetail, null, .{});
+ };
+};
+
+pub const Measure = struct {
+ _proto: *Entry,
+ _detail: ?js.Object,
+
+ const Options = struct {
+ detail: ?js.Object = null,
+ start: ?TimestampOrMark,
+ end: ?TimestampOrMark,
+ duration: ?f64 = null,
+
+ const TimestampOrMark = union(enum) {
+ timestamp: f64,
+ mark: []const u8,
+ };
+ };
+
+ pub fn init(
+ name: []const u8,
+ maybe_detail: ?js.Object,
+ start_timestamp: f64,
+ end_timestamp: f64,
+ maybe_duration: ?f64,
+ page: *Page,
+ ) !*Measure {
+ const duration = maybe_duration orelse (end_timestamp - start_timestamp);
+ if (duration < 0.0) {
+ return error.TypeError;
+ }
+
+ const detail = if (maybe_detail) |d| try d.persist() else null;
+ const m = try page._factory.create(Measure{
+ ._proto = undefined,
+ ._detail = detail,
+ });
+
+ const entry = try page._factory.create(Entry{
+ ._start_time = start_timestamp,
+ ._duration = duration,
+ ._name = try page.dupeString(name),
+ ._type = .{ .measure = m },
+ });
+ m._proto = entry;
+ return m;
+ }
+
+ pub fn getDetail(self: *const Measure) ?js.Object {
+ return self._detail;
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(Measure);
+
+ pub const Meta = struct {
+ pub const name = "PerformanceMeasure";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+ pub const detail = bridge.accessor(Measure.getDetail, null, .{});
+ };
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Performance" {
+ try testing.htmlRunner("performance.html", .{});
+}
diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig
new file mode 100644
index 000000000..cd77ad188
--- /dev/null
+++ b/src/browser/webapi/PerformanceObserver.zig
@@ -0,0 +1,72 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../js/js.zig");
+
+const Entry = @import("Performance.zig").Entry;
+
+// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
+const PerformanceObserver = @This();
+
+pub fn init(callback: js.Function) PerformanceObserver {
+ _ = callback;
+ return .{};
+}
+
+const ObserverOptions = struct {
+ buffered: ?bool = null,
+ durationThreshold: ?f64 = null,
+ entryTypes: ?[]const []const u8 = null,
+ type: ?[]const u8 = null,
+};
+
+pub fn observe(self: *const PerformanceObserver, opts_: ?ObserverOptions) void {
+ _ = self;
+ _ = opts_;
+ return;
+}
+
+pub fn disconnect(self: *PerformanceObserver) void {
+ _ = self;
+}
+
+pub fn takeRecords(_: *const PerformanceObserver) []const Entry {
+ return &.{};
+}
+
+pub fn getSupportedEntryTypes(_: *const PerformanceObserver) [][]const u8 {
+ return &.{};
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(PerformanceObserver);
+
+ pub const Meta = struct {
+ pub const name = "PerformanceObserver";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const constructor = bridge.constructor(PerformanceObserver.init, .{});
+
+ pub const observe = bridge.function(PerformanceObserver.observe, .{});
+ pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{});
+ pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{});
+ pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{ .static = true });
+};
diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig
new file mode 100644
index 000000000..e9af36038
--- /dev/null
+++ b/src/browser/webapi/Range.zig
@@ -0,0 +1,493 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const DocumentFragment = @import("DocumentFragment.zig");
+
+const Range = @This();
+
+_end_offset: u32,
+_start_offset: u32,
+_end_container: *Node,
+_start_container: *Node,
+
+pub fn init(page: *Page) !*Range {
+ // Per spec, a new range starts collapsed at the document's first position
+ const doc = page.document.asNode();
+ return page._factory.create(Range{
+ ._end_offset = 0,
+ ._start_offset = 0,
+ ._end_container = doc,
+ ._start_container = doc,
+ });
+}
+
+pub fn getStartContainer(self: *const Range) *Node {
+ return self._start_container;
+}
+
+pub fn getStartOffset(self: *const Range) u32 {
+ return self._start_offset;
+}
+
+pub fn getEndContainer(self: *const Range) *Node {
+ return self._end_container;
+}
+
+pub fn getEndOffset(self: *const Range) u32 {
+ return self._end_offset;
+}
+
+pub fn getCollapsed(self: *const Range) bool {
+ return self._start_container == self._end_container and
+ self._start_offset == self._end_offset;
+}
+
+pub fn setStart(self: *Range, node: *Node, offset: u32) !void {
+ self._start_container = node;
+ self._start_offset = offset;
+
+ // If start is now after end, collapse to start
+ if (self.isStartAfterEnd()) {
+ self._end_container = self._start_container;
+ self._end_offset = self._start_offset;
+ }
+}
+
+pub fn setEnd(self: *Range, node: *Node, offset: u32) !void {
+ self._end_container = node;
+ self._end_offset = offset;
+
+ // If end is now before start, collapse to end
+ if (self.isStartAfterEnd()) {
+ self._start_container = self._end_container;
+ self._start_offset = self._end_offset;
+ }
+}
+
+pub fn setStartBefore(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset);
+}
+
+pub fn setStartAfter(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset + 1);
+}
+
+pub fn setEndBefore(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setEnd(parent, offset);
+}
+
+pub fn setEndAfter(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setEnd(parent, offset + 1);
+}
+
+pub fn selectNode(self: *Range, node: *Node) !void {
+ const parent = node.parentNode() orelse return error.InvalidNodeType;
+ const offset = parent.getChildIndex(node) orelse return error.NotFound;
+ try self.setStart(parent, offset);
+ try self.setEnd(parent, offset + 1);
+}
+
+pub fn selectNodeContents(self: *Range, node: *Node) !void {
+ const length = node.getLength();
+ try self.setStart(node, 0);
+ try self.setEnd(node, length);
+}
+
+pub fn collapse(self: *Range, to_start: ?bool) void {
+ if (to_start orelse true) {
+ self._end_container = self._start_container;
+ self._end_offset = self._start_offset;
+ } else {
+ self._start_container = self._end_container;
+ self._start_offset = self._end_offset;
+ }
+}
+
+pub fn cloneRange(self: *const Range, page: *Page) !*Range {
+ return page._factory.create(Range{
+ ._end_offset = self._end_offset,
+ ._start_offset = self._start_offset,
+ ._end_container = self._end_container,
+ ._start_container = self._start_container,
+ });
+}
+
+pub fn insertNode(self: *Range, node: *Node, page: *Page) !void {
+ // Insert node at the start of the range
+ const container = self._start_container;
+ const offset = self._start_offset;
+
+ if (container.is(Node.CData)) |_| {
+ // If container is a text node, we need to split it
+ const parent = container.parentNode() orelse return error.InvalidNodeType;
+
+ if (offset == 0) {
+ _ = try parent.insertBefore(node, container, page);
+ } else {
+ const text_data = container.getData();
+ if (offset >= text_data.len) {
+ _ = try parent.insertBefore(node, container.nextSibling(), page);
+ } else {
+ // Split the text node into before and after parts
+ const before_text = text_data[0..offset];
+ const after_text = text_data[offset..];
+
+ const before = try page.createTextNode(before_text);
+ const after = try page.createTextNode(after_text);
+
+ _ = try parent.replaceChild(before, container, page);
+ _ = try parent.insertBefore(node, before.nextSibling(), page);
+ _ = try parent.insertBefore(after, node.nextSibling(), page);
+ }
+ }
+ } else {
+ // Container is an element, insert at offset
+ const ref_child = container.getChildAt(offset);
+ _ = try container.insertBefore(node, ref_child, page);
+ }
+
+ // Update range to be after the inserted node
+ if (self._start_container == self._end_container) {
+ self._end_offset += 1;
+ }
+}
+
+pub fn deleteContents(self: *Range, page: *Page) !void {
+ if (self.getCollapsed()) {
+ return;
+ }
+
+ // Simple case: same container
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |_| {
+ // Delete part of text node
+ const text_data = self._start_container.getData();
+ const new_text = try std.mem.concat(
+ page.arena,
+ u8,
+ &.{ text_data[0..self._start_offset], text_data[self._end_offset..] },
+ );
+ self._start_container.setData(new_text);
+ } else {
+ // Delete child nodes in range
+ var offset = self._start_offset;
+ while (offset < self._end_offset) : (offset += 1) {
+ if (self._start_container.getChildAt(self._start_offset)) |child| {
+ _ = try self._start_container.removeChild(child, page);
+ }
+ }
+ }
+ self.collapse(true);
+ return;
+ }
+
+ // Complex case: different containers - simplified implementation
+ // Just collapse the range for now
+ self.collapse(true);
+}
+
+pub fn cloneContents(self: *const Range, page: *Page) !*DocumentFragment {
+ const fragment = try DocumentFragment.init(page);
+
+ if (self.getCollapsed()) return fragment;
+
+ // Simple case: same container
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |_| {
+ // Clone part of text node
+ const text_data = self._start_container.getData();
+ if (self._start_offset < text_data.len and self._end_offset <= text_data.len) {
+ const cloned_text = text_data[self._start_offset..self._end_offset];
+ const text_node = try page.createTextNode(cloned_text);
+ _ = try fragment.asNode().appendChild(text_node, page);
+ }
+ } else {
+ // Clone child nodes in range
+ var offset = self._start_offset;
+ while (offset < self._end_offset) : (offset += 1) {
+ if (self._start_container.getChildAt(offset)) |child| {
+ const cloned = try child.cloneNode(true, page);
+ _ = try fragment.asNode().appendChild(cloned, page);
+ }
+ }
+ }
+ }
+
+ return fragment;
+}
+
+pub fn extractContents(self: *Range, page: *Page) !*DocumentFragment {
+ const fragment = try self.cloneContents(page);
+ try self.deleteContents(page);
+ return fragment;
+}
+
+pub fn surroundContents(self: *Range, new_parent: *Node, page: *Page) !void {
+ // Extract contents
+ const contents = try self.extractContents(page);
+
+ // Insert the new parent
+ try self.insertNode(new_parent, page);
+
+ // Move contents into new parent
+ _ = try new_parent.appendChild(contents.asNode(), page);
+
+ // Select the new parent's contents
+ try self.selectNodeContents(new_parent);
+}
+
+pub fn createContextualFragment(self: *const Range, html: []const u8, page: *Page) !*DocumentFragment {
+ var context_node = self._start_container;
+
+ // If start container is a text node, use its parent as context
+ if (context_node.is(Node.CData)) |_| {
+ context_node = context_node.parentNode() orelse context_node;
+ }
+
+ const fragment = try DocumentFragment.init(page);
+
+ if (html.len == 0) {
+ return fragment;
+ }
+
+ // Create a temporary element of the same type as the context for parsing
+ // This preserves the parsing context without modifying the original node
+ const temp_node = if (context_node.is(Node.Element)) |el|
+ try page.createElement(el._namespace.toUri(), el.getTagNameLower(), null)
+ else
+ try page.createElement(null, "div", null);
+
+ try page.parseHtmlAsChildren(temp_node, html);
+
+ // Move all parsed children to the fragment
+ // Keep removing first child until temp element is empty
+ const fragment_node = fragment.asNode();
+ while (temp_node.firstChild()) |child| {
+ page.removeNode(temp_node, child, .{ .will_be_reconnected = true });
+ try page.appendNode(fragment_node, child, .{ .child_already_connected = false });
+ }
+
+ return fragment;
+}
+
+pub fn toString(self: *const Range, page: *Page) ![]const u8 {
+ // Simplified implementation: just extract text content
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.writeTextContent(&buf.writer);
+ return buf.written();
+}
+
+fn writeTextContent(self: *const Range, writer: *std.Io.Writer) !void {
+ if (self.getCollapsed()) {
+ return;
+ }
+
+ if (self._start_container == self._end_container) {
+ if (self._start_container.is(Node.CData)) |cdata| {
+ const data = cdata.getData();
+ if (self._start_offset < data.len and self._end_offset <= data.len) {
+ try writer.writeAll(data[self._start_offset..self._end_offset]);
+ }
+ }
+ // For elements, would need to iterate children
+ return;
+ }
+
+ // Complex case: different containers - would need proper tree walking
+ // For now, just return empty
+}
+
+fn isStartAfterEnd(self: *const Range) bool {
+ return compareBoundaryPoints(
+ self._start_container,
+ self._start_offset,
+ self._end_container,
+ self._end_offset,
+ ) == .after;
+}
+
+const BoundaryComparison = enum {
+ before,
+ equal,
+ after,
+};
+
+/// Compare two boundary points in tree order
+/// Returns whether (nodeA, offsetA) is before/equal/after (nodeB, offsetB)
+fn compareBoundaryPoints(
+ node_a: *Node,
+ offset_a: u32,
+ node_b: *Node,
+ offset_b: u32,
+) BoundaryComparison {
+ // If same container, just compare offsets
+ if (node_a == node_b) {
+ if (offset_a < offset_b) return .before;
+ if (offset_a > offset_b) return .after;
+ return .equal;
+ }
+
+ // Check if one contains the other
+ if (isAncestorOf(node_a, node_b)) {
+ // A contains B, so A's position comes before B
+ // But we need to check if the offset in A comes after B
+ var child = node_b;
+ var parent = child.parentNode();
+ while (parent) |p| {
+ if (p == node_a) {
+ const child_index = p.getChildIndex(child) orelse unreachable;
+ if (offset_a <= child_index) {
+ return .before;
+ }
+ return .after;
+ }
+ child = p;
+ parent = p.parentNode();
+ }
+ unreachable;
+ }
+
+ if (isAncestorOf(node_b, node_a)) {
+ // B contains A, so B's position comes before A
+ var child = node_a;
+ var parent = child.parentNode();
+ while (parent) |p| {
+ if (p == node_b) {
+ const child_index = p.getChildIndex(child) orelse unreachable;
+ if (child_index < offset_b) {
+ return .before;
+ }
+ return .after;
+ }
+ child = p;
+ parent = p.parentNode();
+ }
+ unreachable;
+ }
+
+ // Neither contains the other, find their relative position in tree order
+ // Walk up from A to find all ancestors
+ var current = node_a;
+ var a_count: usize = 0;
+ var a_ancestors: [64]*Node = undefined;
+ while (a_count < 64) {
+ a_ancestors[a_count] = current;
+ a_count += 1;
+ current = current.parentNode() orelse break;
+ }
+
+ // Walk up from B and find first common ancestor
+ current = node_b;
+ while (current.parentNode()) |parent| {
+ for (a_ancestors[0..a_count]) |ancestor| {
+ if (ancestor != parent) {
+ continue;
+ }
+
+ // Found common ancestor
+ // Now compare positions of the children in this ancestor
+ const a_child = blk: {
+ var node = node_a;
+ while (node.parentNode()) |p| {
+ if (p == parent) break :blk node;
+ node = p;
+ }
+ unreachable;
+ };
+ const b_child = current;
+
+ const a_index = parent.getChildIndex(a_child) orelse unreachable;
+ const b_index = parent.getChildIndex(b_child) orelse unreachable;
+
+ if (a_index < b_index) {
+ return .before;
+ }
+ if (a_index > b_index) {
+ return .after;
+ }
+ return .equal;
+ }
+ current = parent;
+ }
+
+ // Should not reach here if nodes are in the same tree
+ return .before;
+}
+
+fn isAncestorOf(potential_ancestor: *Node, node: *Node) bool {
+ var current = node.parentNode();
+ while (current) |parent| {
+ if (parent == potential_ancestor) {
+ return true;
+ }
+ current = parent.parentNode();
+ }
+ return false;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Range);
+
+ pub const Meta = struct {
+ pub const name = "Range";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Range.init, .{});
+ pub const startContainer = bridge.accessor(Range.getStartContainer, null, .{});
+ pub const startOffset = bridge.accessor(Range.getStartOffset, null, .{});
+ pub const endContainer = bridge.accessor(Range.getEndContainer, null, .{});
+ pub const endOffset = bridge.accessor(Range.getEndOffset, null, .{});
+ pub const collapsed = bridge.accessor(Range.getCollapsed, null, .{});
+ 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 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, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Range" {
+ try testing.htmlRunner("range.html", .{});
+}
diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig
new file mode 100644
index 000000000..cc78e9c57
--- /dev/null
+++ b/src/browser/webapi/ResizeObserver.zig
@@ -0,0 +1,64 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const Element = @import("Element.zig");
+
+pub const ResizeObserver = @This();
+
+fn init(cbk: js.Function) ResizeObserver {
+ _ = cbk;
+ return .{};
+}
+
+const Options = struct {
+ box: []const u8,
+};
+pub fn observe(self: *const ResizeObserver, element: *Element, options_: ?Options) void {
+ _ = self;
+ _ = element;
+ _ = options_;
+ return;
+}
+
+pub fn unobserve(self: *const ResizeObserver, element: *Element) void {
+ _ = self;
+ _ = element;
+ return;
+}
+
+pub fn disconnect(self: *const ResizeObserver) void {
+ _ = self;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(ResizeObserver);
+
+ pub const Meta = struct {
+ pub const name = "ResizeObserver";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const constructor = bridge.constructor(ResizeObserver.init, .{});
+ pub const observe = bridge.function(ResizeObserver.observe, .{});
+ pub const disconnect = bridge.function(ResizeObserver.disconnect, .{});
+};
diff --git a/src/browser/webapi/Screen.zig b/src/browser/webapi/Screen.zig
new file mode 100644
index 000000000..a9e28e8c5
--- /dev/null
+++ b/src/browser/webapi/Screen.zig
@@ -0,0 +1,130 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../js/js.zig");
+const Page = @import("../Page.zig");
+const EventTarget = @import("EventTarget.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ Screen,
+ Orientation,
+ };
+}
+
+const Screen = @This();
+
+_proto: *EventTarget,
+_orientation: ?*Orientation = null,
+
+pub fn init(page: *Page) !*Screen {
+ return page._factory.eventTarget(Screen{
+ ._proto = undefined,
+ ._orientation = null,
+ });
+}
+
+pub fn asEventTarget(self: *Screen) *EventTarget {
+ return self._proto;
+}
+
+pub fn getWidth(_: *const Screen) u32 {
+ return 1920;
+}
+
+pub fn getHeight(_: *const Screen) u32 {
+ return 1080;
+}
+
+pub fn getAvailWidth(_: *const Screen) u32 {
+ return 1920;
+}
+
+pub fn getAvailHeight(_: *const Screen) u32 {
+ return 1040; // 40px reserved for taskbar/dock
+}
+
+pub fn getColorDepth(_: *const Screen) u32 {
+ return 24;
+}
+
+pub fn getPixelDepth(_: *const Screen) u32 {
+ return 24;
+}
+
+pub fn getOrientation(self: *Screen, page: *Page) !*Orientation {
+ if (self._orientation) |orientation| {
+ return orientation;
+ }
+ const orientation = try Orientation.init(page);
+ self._orientation = orientation;
+ return orientation;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Screen);
+
+ pub const Meta = struct {
+ pub const name = "Screen";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const width = bridge.accessor(Screen.getWidth, null, .{});
+ pub const height = bridge.accessor(Screen.getHeight, null, .{});
+ pub const availWidth = bridge.accessor(Screen.getAvailWidth, null, .{});
+ pub const availHeight = bridge.accessor(Screen.getAvailHeight, null, .{});
+ pub const colorDepth = bridge.accessor(Screen.getColorDepth, null, .{});
+ pub const pixelDepth = bridge.accessor(Screen.getPixelDepth, null, .{});
+ pub const orientation = bridge.accessor(Screen.getOrientation, null, .{});
+};
+
+pub const Orientation = struct {
+ _proto: *EventTarget,
+
+ pub fn init(page: *Page) !*Orientation {
+ return page._factory.eventTarget(Orientation{
+ ._proto = undefined,
+ });
+ }
+
+ pub fn asEventTarget(self: *Orientation) *EventTarget {
+ return self._proto;
+ }
+
+ pub fn getAngle(_: *const Orientation) u32 {
+ return 0;
+ }
+
+ pub fn getType(_: *const Orientation) []const u8 {
+ return "landscape-primary";
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(Orientation);
+
+ pub const Meta = struct {
+ pub const name = "ScreenOrientation";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const angle = bridge.accessor(Orientation.getAngle, null, .{});
+ pub const @"type" = bridge.accessor(Orientation.getType, null, .{});
+ };
+};
diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig
new file mode 100644
index 000000000..9fb11c079
--- /dev/null
+++ b/src/browser/webapi/ShadowRoot.zig
@@ -0,0 +1,97 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const DocumentFragment = @import("DocumentFragment.zig");
+const Element = @import("Element.zig");
+
+const ShadowRoot = @This();
+
+pub const Mode = enum {
+ open,
+ closed,
+
+ pub fn fromString(str: []const u8) !Mode {
+ return std.meta.stringToEnum(Mode, str) orelse error.InvalidMode;
+ }
+};
+
+_proto: *DocumentFragment,
+_mode: Mode,
+_host: *Element,
+_elements_by_id: std.StringHashMapUnmanaged(*Element) = .{},
+
+pub fn init(host: *Element, mode: Mode, page: *Page) !*ShadowRoot {
+ return page._factory.documentFragment(ShadowRoot{
+ ._proto = undefined,
+ ._mode = mode,
+ ._host = host,
+ });
+}
+
+pub fn asDocumentFragment(self: *ShadowRoot) *DocumentFragment {
+ return self._proto;
+}
+
+pub fn asNode(self: *ShadowRoot) *Node {
+ return self._proto.asNode();
+}
+
+pub fn asEventTarget(self: *ShadowRoot) *@import("EventTarget.zig") {
+ return self.asNode().asEventTarget();
+}
+
+pub fn className(_: *const ShadowRoot) []const u8 {
+ return "[object ShadowRoot]";
+}
+
+pub fn getMode(self: *const ShadowRoot) []const u8 {
+ return @tagName(self._mode);
+}
+
+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 const JsApi = struct {
+ pub const bridge = js.Bridge(ShadowRoot);
+
+ pub const Meta = struct {
+ pub const name = "ShadowRoot";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const mode = bridge.accessor(ShadowRoot.getMode, null, .{});
+ pub const host = bridge.accessor(ShadowRoot.getHost, null, .{});
+ pub const getElementById = bridge.function(ShadowRoot.getElementById, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: ShadowRoot" {
+ try testing.htmlRunner("shadowroot", .{});
+}
diff --git a/src/browser/webapi/TreeWalker.zig b/src/browser/webapi/TreeWalker.zig
new file mode 100644
index 000000000..b6df32fd1
--- /dev/null
+++ b/src/browser/webapi/TreeWalker.zig
@@ -0,0 +1,149 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const Node = @import("Node.zig");
+const Element = @import("Element.zig");
+
+pub const Full = TreeWalker(.full);
+pub const FullExcludeSelf = TreeWalker(.exclude_self);
+pub const Children = TreeWalker(.children);
+
+const Mode = enum {
+ full,
+ children,
+ exclude_self,
+};
+
+pub fn TreeWalker(comptime mode: Mode) type {
+ return struct {
+ _next: ?*Node,
+ _root: *Node,
+
+ const Self = @This();
+ const Opts = struct {};
+
+ pub fn init(root: *Node, opts: Opts) Self {
+ _ = opts;
+ return .{
+ ._next = firstNext(root),
+ ._root = root,
+ };
+ }
+
+ pub fn next(self: *Self) ?*Node {
+ const node = self._next orelse return null;
+
+ if (comptime mode == .children) {
+ self._next = Node.linkToNodeOrNull(node._child_link.next);
+ return node;
+ }
+
+ if (node._children) |children| {
+ self._next = children.first();
+ } else if (node._child_link.next) |n| {
+ self._next = Node.linkToNode(n);
+ } else {
+ // No children, no next sibling - walk up until we find a next sibling or hit root
+ var current = node._parent;
+ while (current) |parent| {
+ if (parent == self._root) {
+ self._next = null;
+ break;
+ }
+ if (parent._child_link.next) |next_sibling| {
+ self._next = Node.linkToNode(next_sibling);
+ break;
+ }
+ current = parent._parent;
+ } else {
+ self._next = null;
+ }
+ }
+ return node;
+ }
+
+ pub fn reset(self: *Self) void {
+ self._next = firstNext(self._root);
+ }
+
+ pub fn contains(self: *const Self, target: *const Node) bool {
+ const root = self._root;
+
+ if (comptime mode == .children) {
+ var it = root.childrenIterator();
+ while (it.next()) |child| {
+ if (child == target) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ var node = target;
+ if ((comptime mode == .exclude_self) and node == root) {
+ return false;
+ }
+
+ while (true) {
+ if (node == root) {
+ return true;
+ }
+ node = node._parent orelse return false;
+ }
+ }
+
+ pub fn clone(self: *const Self) Self {
+ const root = self._root;
+ return .{
+ ._next = firstNext(root),
+ ._root = root,
+ };
+ }
+
+ fn firstNext(root: *Node) ?*Node {
+ return switch (comptime mode) {
+ .full => root,
+ .exclude_self => root.firstChild(),
+ .children => root.firstChild(),
+ };
+ }
+
+ pub const Elements = struct {
+ tw: Self,
+
+ pub fn init(root: *Node, comptime opts: Opts) Elements {
+ return .{
+ .tw = Self.init(root, opts),
+ };
+ }
+
+ pub fn next(self: *Elements) ?*Element {
+ while (self.tw.next()) |node| {
+ if (node.is(Element)) |el| {
+ return el;
+ }
+ }
+ return null;
+ }
+
+ pub fn reset(self: *Elements) void {
+ self.tw.reset();
+ }
+ };
+ };
+}
diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig
new file mode 100644
index 000000000..766bd3c20
--- /dev/null
+++ b/src/browser/webapi/URL.zig
@@ -0,0 +1,271 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const U = @import("../URL.zig");
+const Page = @import("../Page.zig");
+const URLSearchParams = @import("net/URLSearchParams.zig");
+
+const Allocator = std.mem.Allocator;
+
+const URL = @This();
+
+_raw: [:0]const u8,
+_arena: ?Allocator = null,
+_search_params: ?*URLSearchParams = null,
+
+// convenience
+pub const resolve = @import("../URL.zig").resolve;
+pub const eqlDocument = @import("../URL.zig").eqlDocument;
+
+pub fn canParse(url: []const u8, base_: ?[]const u8, page: *Page) bool {
+ _ = page;
+ const url_is_absolute = U.isCompleteHTTPUrl(url);
+
+ if (base_) |b| {
+ // Base must be valid even if URL is absolute
+ if (!U.isCompleteHTTPUrl(b)) {
+ return false;
+ }
+ return true;
+ } else if (!url_is_absolute) {
+ return false;
+ } else {
+ return true;
+ }
+}
+
+pub fn init(url: [:0]const u8, base_: ?[:0]const u8, page: *Page) !*URL {
+ const url_is_absolute = @import("../URL.zig").isCompleteHTTPUrl(url);
+
+ const base = if (base_) |b| blk: {
+ // If URL is absolute, base is ignored (but we still use page.url internally)
+ if (url_is_absolute) {
+ break :blk page.url;
+ }
+ // For relative URLs, base must be a valid absolute URL
+ if (!@import("../URL.zig").isCompleteHTTPUrl(b)) {
+ return error.TypeError;
+ }
+ break :blk b;
+ } else if (!url_is_absolute) {
+ return error.TypeError;
+ } else page.url;
+
+ const arena = page.arena;
+ const raw = try resolve(arena, base, url, .{ .always_dupe = true });
+
+ return page._factory.create(URL{
+ ._raw = raw,
+ ._arena = arena,
+ });
+}
+
+pub fn getUsername(self: *const URL) []const u8 {
+ return U.getUsername(self._raw);
+}
+
+pub fn getPassword(self: *const URL) []const u8 {
+ return U.getPassword(self._raw);
+}
+
+pub fn getPathname(self: *const URL) []const u8 {
+ return U.getPathname(self._raw);
+}
+
+pub fn getProtocol(self: *const URL) []const u8 {
+ return U.getProtocol(self._raw);
+}
+
+pub fn getHostname(self: *const URL) []const u8 {
+ return U.getHostname(self._raw);
+}
+
+pub fn getHost(self: *const URL) []const u8 {
+ return U.getHost(self._raw);
+}
+
+pub fn getPort(self: *const URL) []const u8 {
+ return U.getPort(self._raw);
+}
+
+pub fn getOrigin(self: *const URL, page: *const Page) ![]const u8 {
+ return (try U.getOrigin(page.call_arena, self._raw)) orelse {
+ // yes, a null string, that's what the spec wants
+ return "null";
+ };
+}
+
+pub fn getSearch(self: *const URL, page: *const Page) ![]const u8 {
+ // If searchParams has been accessed, generate search from it
+ if (self._search_params) |sp| {
+ if (sp.getSize() == 0) {
+ return "";
+ }
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try buf.writer.writeByte('?');
+ try sp.toString(&buf.writer);
+ return buf.written();
+ }
+ return U.getSearch(self._raw);
+}
+
+pub fn getHash(self: *const URL) []const u8 {
+ return U.getHash(self._raw);
+}
+
+pub fn getSearchParams(self: *URL, page: *Page) !*URLSearchParams {
+ if (self._search_params) |sp| {
+ return sp;
+ }
+
+ // Get current search string (without the '?')
+ const search = try self.getSearch(page);
+ const search_value = if (search.len > 0) search[1..] else "";
+
+ const params = try URLSearchParams.init(.{ .query_string = search_value }, page);
+ self._search_params = params;
+ return params;
+}
+
+pub fn setHref(self: *URL, value: []const u8, page: *Page) !void {
+ const base = if (U.isCompleteHTTPUrl(value)) page.url else self._raw;
+ const raw = try U.resolve(self._arena orelse page.arena, base, value, .{ .always_dupe = true });
+ self._raw = raw;
+
+ // Update existing searchParams if it exists
+ if (self._search_params) |sp| {
+ const search = U.getSearch(raw);
+ const search_value = if (search.len > 0) search[1..] else "";
+ try sp.updateFromString(search_value, page);
+ }
+}
+
+pub fn setProtocol(self: *URL, value: []const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setProtocol(self._raw, value, allocator);
+}
+
+pub fn setHost(self: *URL, value: []const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setHost(self._raw, value, allocator);
+}
+
+pub fn setHostname(self: *URL, value: []const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setHostname(self._raw, value, allocator);
+}
+
+pub fn setPort(self: *URL, value: ?[]const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setPort(self._raw, value, allocator);
+}
+
+pub fn setPathname(self: *URL, value: []const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setPathname(self._raw, value, allocator);
+}
+
+pub fn setSearch(self: *URL, value: []const u8, page: *Page) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setSearch(self._raw, value, allocator);
+
+ // Update existing searchParams if it exists
+ if (self._search_params) |sp| {
+ const search = U.getSearch(self._raw);
+ const search_value = if (search.len > 0) search[1..] else "";
+ try sp.updateFromString(search_value, page);
+ }
+}
+
+pub fn setHash(self: *URL, value: []const u8) !void {
+ const allocator = self._arena orelse return error.NoAllocator;
+ self._raw = try U.setHash(self._raw, value, allocator);
+}
+
+pub fn toString(self: *const URL, page: *const Page) ![:0]const u8 {
+ const sp = self._search_params orelse {
+ return self._raw;
+ };
+
+ // Rebuild URL from searchParams
+ const raw = self._raw;
+
+ // Find the base (everything before ? or #)
+ const base_end = std.mem.indexOfAnyPos(u8, raw, 0, "?#") orelse raw.len;
+ const base = raw[0..base_end];
+
+ // Get the hash if it exists
+ const hash = self.getHash();
+
+ // Build the new URL string
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try buf.writer.writeAll(base);
+
+ // Add / if missing (e.g., "https://example.com" -> "https://example.com/")
+ // Only add if pathname is just "/" and not already in the base
+ const pathname = U.getPathname(raw);
+ if (std.mem.eql(u8, pathname, "/") and !std.mem.endsWith(u8, base, "/")) {
+ try buf.writer.writeByte('/');
+ }
+
+ // Only add ? if there are params
+ if (sp.getSize() > 0) {
+ try buf.writer.writeByte('?');
+ try sp.toString(&buf.writer);
+ }
+
+ try buf.writer.writeAll(hash);
+ try buf.writer.writeByte(0);
+
+ return buf.written()[0 .. buf.written().len - 1 :0];
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(URL);
+
+ pub const Meta = struct {
+ pub const name = "URL";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(URL.init, .{});
+ pub const canParse = bridge.function(URL.canParse, .{ .static = true });
+ pub const toString = bridge.function(URL.toString, .{});
+ pub const toJSON = bridge.function(URL.toString, .{});
+ pub const href = bridge.accessor(URL.toString, URL.setHref, .{});
+ pub const search = bridge.accessor(URL.getSearch, URL.setSearch, .{});
+ pub const hash = bridge.accessor(URL.getHash, URL.setHash, .{});
+ pub const pathname = bridge.accessor(URL.getPathname, URL.setPathname, .{});
+ pub const username = bridge.accessor(URL.getUsername, null, .{});
+ pub const password = bridge.accessor(URL.getPassword, null, .{});
+ pub const hostname = bridge.accessor(URL.getHostname, URL.setHostname, .{});
+ pub const host = bridge.accessor(URL.getHost, URL.setHost, .{});
+ pub const port = bridge.accessor(URL.getPort, URL.setPort, .{});
+ pub const origin = bridge.accessor(URL.getOrigin, null, .{});
+ pub const protocol = bridge.accessor(URL.getProtocol, URL.setProtocol, .{});
+ pub const searchParams = bridge.accessor(URL.getSearchParams, null, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: URL" {
+ try testing.htmlRunner("url.html", .{});
+}
diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig
new file mode 100644
index 000000000..51213ecda
--- /dev/null
+++ b/src/browser/webapi/Window.zig
@@ -0,0 +1,560 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+const builtin = @import("builtin");
+
+const log = @import("../../log.zig");
+const Page = @import("../Page.zig");
+const Console = @import("Console.zig");
+const History = @import("History.zig");
+const Navigation = @import("navigation/Navigation.zig");
+const Crypto = @import("Crypto.zig");
+const CSS = @import("CSS.zig");
+const Navigator = @import("Navigator.zig");
+const Screen = @import("Screen.zig");
+const Performance = @import("Performance.zig");
+const Document = @import("Document.zig");
+const Location = @import("Location.zig");
+const Fetch = @import("net/Fetch.zig");
+const EventTarget = @import("EventTarget.zig");
+const ErrorEvent = @import("event/ErrorEvent.zig");
+const MessageEvent = @import("event/MessageEvent.zig");
+const MediaQueryList = @import("css/MediaQueryList.zig");
+const storage = @import("storage/storage.zig");
+const Element = @import("Element.zig");
+const CSSStyleDeclaration = @import("css/CSSStyleDeclaration.zig");
+const CustomElementRegistry = @import("CustomElementRegistry.zig");
+
+const Window = @This();
+
+_proto: *EventTarget,
+_document: *Document,
+_css: CSS = .init,
+_crypto: Crypto = .init,
+_console: Console = .init,
+_navigator: Navigator = .init,
+_screen: *Screen,
+_performance: Performance,
+_storage_bucket: *storage.Bucket,
+_on_load: ?js.Function = null,
+_on_pageshow: ?js.Function = null,
+_on_popstate: ?js.Function = null,
+_on_error: ?js.Function = null, // TODO: invoke on error?
+_on_unhandled_rejection: ?js.Function = null, // TODO: invoke on error
+_location: *Location,
+_timer_id: u30 = 0,
+_timers: std.AutoHashMapUnmanaged(u32, *ScheduleCallback) = .{},
+_custom_elements: CustomElementRegistry = .{},
+
+pub fn asEventTarget(self: *Window) *EventTarget {
+ return self._proto;
+}
+
+pub fn getSelf(self: *Window) *Window {
+ return self;
+}
+
+pub fn getWindow(self: *Window) *Window {
+ return self;
+}
+
+pub fn getDocument(self: *Window) *Document {
+ return self._document;
+}
+
+pub fn getConsole(self: *Window) *Console {
+ return &self._console;
+}
+
+pub fn getNavigator(self: *Window) *Navigator {
+ return &self._navigator;
+}
+
+pub fn getScreen(self: *Window) *Screen {
+ return self._screen;
+}
+
+pub fn getCrypto(self: *Window) *Crypto {
+ return &self._crypto;
+}
+
+pub fn getCSS(self: *Window) *CSS {
+ return &self._css;
+}
+
+pub fn getPerformance(self: *Window) *Performance {
+ return &self._performance;
+}
+
+pub fn getLocalStorage(self: *const Window) *storage.Lookup {
+ return &self._storage_bucket.local;
+}
+
+pub fn getSessionStorage(self: *const Window) *storage.Lookup {
+ return &self._storage_bucket.session;
+}
+
+pub fn getLocation(self: *const Window) *Location {
+ return self._location;
+}
+
+pub fn getHistory(_: *Window, page: *Page) *History {
+ return &page._session.history;
+}
+
+pub fn getNavigation(_: *Window, page: *Page) *Navigation {
+ return &page._session.navigation;
+}
+
+pub fn getCustomElements(self: *Window) *CustomElementRegistry {
+ return &self._custom_elements;
+}
+
+pub fn getOnLoad(self: *const Window) ?js.Function {
+ return self._on_load;
+}
+
+pub fn setOnLoad(self: *Window, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_load = cb;
+ } else {
+ self._on_load = null;
+ }
+}
+
+pub fn getOnPageShow(self: *const Window) ?js.Function {
+ return self._on_pageshow;
+}
+
+pub fn setOnPageShow(self: *Window, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_pageshow = cb;
+ } else {
+ self._on_pageshow = null;
+ }
+}
+
+pub fn getOnPopState(self: *const Window) ?js.Function {
+ return self._on_popstate;
+}
+
+pub fn setOnPopState(self: *Window, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_popstate = cb;
+ } else {
+ self._on_popstate = null;
+ }
+}
+
+pub fn getOnError(self: *const Window) ?js.Function {
+ return self._on_error;
+}
+
+pub fn setOnError(self: *Window, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_error = cb;
+ } else {
+ self._on_error = null;
+ }
+}
+
+pub fn getOnUnhandledRejection(self: *const Window) ?js.Function {
+ return self._on_unhandled_rejection;
+}
+
+pub fn setOnUnhandledRejection(self: *Window, cb_: ?js.Function) !void {
+ if (cb_) |cb| {
+ self._on_unhandled_rejection = cb;
+ } else {
+ self._on_unhandled_rejection = null;
+ }
+}
+
+pub fn fetch(_: *const Window, input: Fetch.Input, options: ?Fetch.InitOpts, page: *Page) !js.Promise {
+ return Fetch.init(input, options, page);
+}
+
+pub fn setTimeout(self: *Window, cb: js.Function, delay_ms: ?u32, params: []js.Object, page: *Page) !u32 {
+ return self.scheduleCallback(cb, delay_ms orelse 0, .{
+ .repeat = false,
+ .params = params,
+ .low_priority = false,
+ .name = "window.setTimeout",
+ }, page);
+}
+
+pub fn setInterval(self: *Window, cb: js.Function, delay_ms: ?u32, params: []js.Object, page: *Page) !u32 {
+ return self.scheduleCallback(cb, delay_ms orelse 0, .{
+ .repeat = true,
+ .params = params,
+ .low_priority = false,
+ .name = "window.setInterval",
+ }, page);
+}
+
+pub fn setImmediate(self: *Window, cb: js.Function, params: []js.Object, page: *Page) !u32 {
+ return self.scheduleCallback(cb, 0, .{
+ .repeat = false,
+ .params = params,
+ .low_priority = false,
+ .name = "window.setImmediate",
+ }, page);
+}
+
+pub fn requestAnimationFrame(self: *Window, cb: js.Function, page: *Page) !u32 {
+ return self.scheduleCallback(cb, 5, .{
+ .repeat = false,
+ .params = &.{},
+ .low_priority = false,
+ .animation_frame = true,
+ .name = "window.requestAnimationFrame",
+ }, page);
+}
+
+pub fn queueMicrotask(_: *Window, cb: js.Function, page: *Page) void {
+ page.js.queueMicrotaskFunc(cb);
+}
+
+pub fn clearTimeout(self: *Window, id: u32) void {
+ var sc = self._timers.get(id) orelse return;
+ sc.removed = true;
+}
+
+pub fn clearInterval(self: *Window, id: u32) void {
+ var sc = self._timers.get(id) orelse return;
+ sc.removed = true;
+}
+
+pub fn clearImmediate(self: *Window, id: u32) void {
+ var sc = self._timers.get(id) orelse return;
+ sc.removed = true;
+}
+
+pub fn cancelAnimationFrame(self: *Window, id: u32) void {
+ var sc = self._timers.get(id) orelse return;
+ sc.removed = true;
+}
+
+const RequestIdleCallbackOpts = struct {
+ timeout: ?u32 = null,
+};
+pub fn requestIdleCallback(self: *Window, cb: js.Function, opts_: ?RequestIdleCallbackOpts, page: *Page) !u32 {
+ const opts = opts_ orelse RequestIdleCallbackOpts{};
+ return self.scheduleCallback(cb, opts.timeout orelse 50, .{
+ .repeat = false,
+ .params = &.{},
+ .low_priority = true,
+ .name = "window.requestIdleCallback",
+ }, page);
+}
+
+pub fn cancelIdleCallback(self: *Window, id: u32) void {
+ var sc = self._timers.get(id) orelse return;
+ sc.removed = true;
+}
+
+pub fn reportError(self: *Window, err: js.Object, page: *Page) !void {
+ const error_event = try ErrorEvent.init("error", .{
+ .@"error" = err,
+ .message = err.toString() catch "Unknown error",
+ .bubbles = false,
+ .cancelable = true,
+ }, page);
+
+ const event = error_event.asEvent();
+ try page._event_manager.dispatch(self.asEventTarget(), event);
+
+ if (comptime builtin.is_test == false) {
+ if (!event._prevent_default) {
+ log.warn(.js, "window.reportError", .{
+ .message = error_event._message,
+ .filename = error_event._filename,
+ .line_number = error_event._line_number,
+ .column_number = error_event._column_number,
+ });
+ }
+ }
+}
+
+pub fn matchMedia(_: *const Window, query: []const u8, page: *Page) !*MediaQueryList {
+ return page._factory.eventTarget(MediaQueryList{
+ ._proto = undefined,
+ ._media = try page.dupeString(query),
+ });
+}
+
+pub fn getComputedStyle(_: *const Window, _: *Element, page: *Page) !*CSSStyleDeclaration {
+ return CSSStyleDeclaration.init(null, page);
+}
+
+pub fn postMessage(self: *Window, message: js.Object, target_origin: ?[]const u8, page: *Page) !void {
+ // For now, we ignore targetOrigin checking and just dispatch the message
+ // In a full implementation, we would validate the origin
+ _ = target_origin;
+
+ // postMessage queues a task (not a microtask), so use the scheduler
+ const origin = try self._location.getOrigin(page);
+ const callback = try page._factory.create(PostMessageCallback{
+ .window = self,
+ .message = try message.persist(),
+ .origin = try page.arena.dupe(u8, origin),
+ .page = page,
+ });
+ errdefer page._factory.destroy(callback);
+
+ try page.scheduler.add(callback, PostMessageCallback.run, 0, .{
+ .name = "postMessage",
+ .low_priority = false,
+ });
+}
+
+pub fn btoa(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
+ const encoded_len = std.base64.standard.Encoder.calcSize(input.len);
+ const encoded = try page.call_arena.alloc(u8, encoded_len);
+ return std.base64.standard.Encoder.encode(encoded, input);
+}
+
+pub fn atob(_: *const Window, input: []const u8, page: *Page) ![]const u8 {
+ const decoded_len = try std.base64.standard.Decoder.calcSizeForSlice(input);
+ const decoded = try page.call_arena.alloc(u8, decoded_len);
+ try std.base64.standard.Decoder.decode(decoded, input);
+ return decoded;
+}
+
+pub fn getLength(_: *const Window) u32 {
+ return 0;
+}
+
+pub fn getInnerWidth(_: *const Window) u32 {
+ return 1920;
+}
+
+pub fn getInnerHeight(_: *const Window) u32 {
+ return 1080;
+}
+
+pub fn getScrollX(_: *const Window) u32 {
+ return 0;
+}
+
+pub fn getScrollY(_: *const Window) u32 {
+ return 0;
+}
+
+const ScheduleOpts = struct {
+ repeat: bool,
+ params: []js.Object,
+ name: []const u8,
+ low_priority: bool = false,
+ animation_frame: bool = false,
+};
+fn scheduleCallback(self: *Window, cb: js.Function, delay_ms: u32, opts: ScheduleOpts, page: *Page) !u32 {
+ if (self._timers.count() > 512) {
+ // these are active
+ return error.TooManyTimeout;
+ }
+
+ const timer_id = self._timer_id +% 1;
+ self._timer_id = timer_id;
+
+ const params = opts.params;
+ var persisted_params: []js.Object = &.{};
+ if (params.len > 0) {
+ persisted_params = try page.arena.alloc(js.Object, params.len);
+ for (params, persisted_params) |a, *ca| {
+ ca.* = try a.persist();
+ }
+ }
+
+ const gop = try self._timers.getOrPut(page.arena, timer_id);
+ if (gop.found_existing) {
+ // 2^31 would have to wrap for this to happen.
+ return error.TooManyTimeout;
+ }
+ errdefer _ = self._timers.remove(timer_id);
+
+ const callback = try page._factory.create(ScheduleCallback{
+ .cb = cb,
+ .page = page,
+ .name = opts.name,
+ .timer_id = timer_id,
+ .params = persisted_params,
+ .animation_frame = opts.animation_frame,
+ .repeat_ms = if (opts.repeat) if (delay_ms == 0) 1 else delay_ms else null,
+ });
+ gop.value_ptr.* = callback;
+ errdefer page._factory.destroy(callback);
+
+ try page.scheduler.add(callback, ScheduleCallback.run, delay_ms, .{
+ .name = opts.name,
+ .low_priority = opts.low_priority,
+ });
+
+ return timer_id;
+}
+
+const ScheduleCallback = struct {
+ // for debugging
+ name: []const u8,
+
+ // window._timers key
+ timer_id: u31,
+
+ // delay, in ms, to repeat. When null, will be removed after the first time
+ repeat_ms: ?u32,
+
+ cb: js.Function,
+
+ page: *Page,
+
+ params: []const js.Object,
+
+ removed: bool = false,
+
+ animation_frame: bool = false,
+
+ fn deinit(self: *ScheduleCallback) void {
+ self.page._factory.destroy(self);
+ }
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *ScheduleCallback = @ptrCast(@alignCast(ctx));
+ const page = self.page;
+ if (self.removed) {
+ _ = page.window._timers.remove(self.timer_id);
+ self.deinit();
+ return null;
+ }
+
+ if (self.animation_frame) {
+ self.cb.call(void, .{page.window._performance.now()}) catch |err| {
+ // a non-JS error
+ log.warn(.js, "window.RAF", .{ .name = self.name, .err = err });
+ };
+ } else {
+ self.cb.call(void, .{self.params}) catch |err| {
+ // a non-JS error
+ log.warn(.js, "window.timer", .{ .name = self.name, .err = err });
+ };
+ }
+
+ if (self.repeat_ms) |ms| {
+ return ms;
+ }
+ defer self.deinit();
+
+ _ = page.window._timers.remove(self.timer_id);
+ page.js.runMicrotasks();
+ return null;
+ }
+};
+
+const PostMessageCallback = struct {
+ window: *Window,
+ message: js.Object,
+ origin: []const u8,
+ page: *Page,
+
+ fn deinit(self: *PostMessageCallback) void {
+ self.page._factory.destroy(self);
+ }
+
+ fn run(ctx: *anyopaque) !?u32 {
+ const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
+ defer self.deinit();
+
+ const message_event = try MessageEvent.init("message", .{
+ .data = self.message,
+ .origin = self.origin,
+ .source = self.window,
+ .bubbles = false,
+ .cancelable = false,
+ }, self.page);
+
+ const event = message_event.asEvent();
+ try self.page._event_manager.dispatch(self.window.asEventTarget(), event);
+
+ return null;
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Window);
+
+ pub const Meta = struct {
+ pub const name = "Window";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const top = bridge.accessor(Window.getWindow, null, .{ .cache = "top" });
+ pub const self = bridge.accessor(Window.getWindow, null, .{ .cache = "self" });
+ pub const window = bridge.accessor(Window.getWindow, null, .{ .cache = "window" });
+ pub const parent = bridge.accessor(Window.getWindow, null, .{ .cache = "parent" });
+ pub const console = bridge.accessor(Window.getConsole, null, .{ .cache = "console" });
+ pub const navigator = bridge.accessor(Window.getNavigator, null, .{ .cache = "navigator" });
+ pub const screen = bridge.accessor(Window.getScreen, null, .{ .cache = "screen" });
+ pub const performance = bridge.accessor(Window.getPerformance, null, .{ .cache = "performance" });
+ pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{ .cache = "localStorage" });
+ pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{ .cache = "sessionStorage" });
+ pub const document = bridge.accessor(Window.getDocument, null, .{ .cache = "document" });
+ pub const location = bridge.accessor(Window.getLocation, null, .{ .cache = "location" });
+ pub const history = bridge.accessor(Window.getHistory, null, .{});
+ pub const navigation = bridge.accessor(Window.getNavigation, null, .{});
+ pub const crypto = bridge.accessor(Window.getCrypto, null, .{ .cache = "crypto" });
+ pub const CSS = bridge.accessor(Window.getCSS, null, .{ .cache = "CSS" });
+ pub const customElements = bridge.accessor(Window.getCustomElements, null, .{ .cache = "customElements" });
+ pub const onload = bridge.accessor(Window.getOnLoad, Window.setOnLoad, .{});
+ pub const onpageshow = bridge.accessor(Window.getOnPageShow, Window.setOnPageShow, .{});
+ pub const onpopstate = bridge.accessor(Window.getOnPopState, Window.setOnPopState, .{});
+ pub const onerror = bridge.accessor(Window.getOnError, Window.getOnError, .{});
+ pub const onunhandledrejection = bridge.accessor(Window.getOnUnhandledRejection, Window.setOnUnhandledRejection, .{});
+ pub const fetch = bridge.function(Window.fetch, .{});
+ pub const queueMicrotask = bridge.function(Window.queueMicrotask, .{});
+ pub const setTimeout = bridge.function(Window.setTimeout, .{});
+ pub const clearTimeout = bridge.function(Window.clearTimeout, .{});
+ pub const setInterval = bridge.function(Window.setInterval, .{});
+ pub const clearInterval = bridge.function(Window.clearInterval, .{});
+ pub const setImmediate = bridge.function(Window.setImmediate, .{});
+ pub const clearImmediate = bridge.function(Window.clearImmediate, .{});
+ pub const requestAnimationFrame = bridge.function(Window.requestAnimationFrame, .{});
+ pub const cancelAnimationFrame = bridge.function(Window.cancelAnimationFrame, .{});
+ pub const requestIdleCallback = bridge.function(Window.requestIdleCallback, .{});
+ pub const cancelIdleCallback = bridge.function(Window.cancelIdleCallback, .{});
+ pub const matchMedia = bridge.function(Window.matchMedia, .{});
+ pub const postMessage = bridge.function(Window.postMessage, .{});
+ pub const btoa = bridge.function(Window.btoa, .{});
+ pub const atob = bridge.function(Window.atob, .{});
+ pub const reportError = bridge.function(Window.reportError, .{});
+ pub const frames = bridge.accessor(Window.getWindow, null, .{ .cache = "frames" });
+ pub const getComputedStyle = bridge.function(Window.getComputedStyle, .{});
+ pub const length = bridge.accessor(Window.getLength, null, .{ .cache = "length" });
+ pub const innerWidth = bridge.accessor(Window.getInnerWidth, null, .{ .cache = "innerWidth" });
+ pub const innerHeight = bridge.accessor(Window.getInnerHeight, null, .{ .cache = "innerHeight" });
+ pub const scrollX = bridge.accessor(Window.getScrollX, null, .{ .cache = "scrollX" });
+ pub const scrollY = bridge.accessor(Window.getScrollY, null, .{ .cache = "scrollY" });
+ pub const pageXOffset = bridge.accessor(Window.getScrollX, null, .{ .cache = "pageXOffset" });
+ pub const pageYOffset = bridge.accessor(Window.getScrollY, null, .{ .cache = "pageYOffset" });
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: Window" {
+ try testing.htmlRunner("window", .{});
+}
diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig
new file mode 100644
index 000000000..83f42e20b
--- /dev/null
+++ b/src/browser/webapi/XMLSerializer.zig
@@ -0,0 +1,60 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../js/js.zig");
+
+const Page = @import("../Page.zig");
+const Node = @import("Node.zig");
+const dump = @import("../dump.zig");
+
+const XMLSerializer = @This();
+
+pub fn init() XMLSerializer {
+ return .{};
+}
+
+pub fn serializeToString(self: *const XMLSerializer, node: *Node, page: *Page) ![]const u8 {
+ _ = self;
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ if (node.is(Node.Document)) |doc| {
+ try dump.root(doc, .{ .shadow = .skip }, &buf.writer, page);
+ } else {
+ try dump.deep(node, .{ .shadow = .skip }, &buf.writer, page);
+ }
+ return buf.written();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(XMLSerializer);
+
+ pub const Meta = struct {
+ pub const name = "XMLSerializer";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const constructor = bridge.constructor(XMLSerializer.init, .{});
+ pub const serializeToString = bridge.function(XMLSerializer.serializeToString, .{});
+};
+
+const testing = @import("../../testing.zig");
+test "WebApi: XMLSerializer" {
+ try testing.htmlRunner("xmlserializer.html", .{});
+}
diff --git a/src/browser/webapi/animation/Animation.zig b/src/browser/webapi/animation/Animation.zig
new file mode 100644
index 000000000..2fecfa954
--- /dev/null
+++ b/src/browser/webapi/animation/Animation.zig
@@ -0,0 +1,49 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+const Animation = @This();
+
+pub fn init() !Animation {
+ return .{};
+}
+
+pub fn play(_: *Animation) void {}
+pub fn pause(_: *Animation) void {}
+pub fn cancel(_: *Animation) void {}
+pub fn finish(_: *Animation) void {}
+pub fn reverse(_: *Animation) void {}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Animation);
+
+ pub const Meta = struct {
+ pub const name = "Animation";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const empty_with_no_proto = true;
+ };
+
+ pub const play = bridge.function(Animation.play, .{});
+ pub const pause = bridge.function(Animation.pause, .{});
+ pub const cancel = bridge.function(Animation.cancel, .{});
+ pub const finish = bridge.function(Animation.finish, .{});
+ pub const reverse = bridge.function(Animation.reverse, .{});
+};
diff --git a/src/browser/html/iframe.zig b/src/browser/webapi/cdata/CDATASection.zig
similarity index 67%
rename from src/browser/html/iframe.zig
rename to src/browser/webapi/cdata/CDATASection.zig
index 00b5ab812..9a9d9e08c 100644
--- a/src/browser/html/iframe.zig
+++ b/src/browser/webapi/cdata/CDATASection.zig
@@ -15,14 +15,21 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-const std = @import("std");
-const parser = @import("../netsurf.zig");
-const HTMLElement = @import("elements.zig").HTMLElement;
+const js = @import("../../js/js.zig");
-// https://html.spec.whatwg.org/multipage/iframe-embed-object.html#htmliframeelement
-pub const HTMLIFrameElement = struct {
- pub const Self = parser.IFrame;
- pub const prototype = *HTMLElement;
- pub const subtype = .node;
+const Text = @import("Text.zig");
+
+const CDATASection = @This();
+
+_proto: *Text,
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CDATASection);
+
+ pub const Meta = struct {
+ pub const name = "CDATASection";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
};
diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig
new file mode 100644
index 000000000..dca43b25d
--- /dev/null
+++ b/src/browser/webapi/cdata/Comment.zig
@@ -0,0 +1,43 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+const CData = @import("../CData.zig");
+
+const Comment = @This();
+
+_proto: *CData,
+
+pub fn init(content: ?[]const u8, page: *Page) !*Comment {
+ const node = try page.createComment(content orelse "");
+ return node.as(Comment);
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Comment);
+
+ pub const Meta = struct {
+ pub const name = "Comment";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(Comment.init, .{});
+};
diff --git a/src/browser/encoding/encoding.zig b/src/browser/webapi/cdata/Text.zig
similarity index 60%
rename from src/browser/encoding/encoding.zig
rename to src/browser/webapi/cdata/Text.zig
index 97a16f71f..ad440348c 100644
--- a/src/browser/encoding/encoding.zig
+++ b/src/browser/webapi/cdata/Text.zig
@@ -16,7 +16,26 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
-pub const Interfaces = .{
- @import("TextDecoder.zig"),
- @import("TextEncoder.zig"),
+const js = @import("../../js/js.zig");
+
+const CData = @import("../CData.zig");
+
+const Text = @This();
+
+_proto: *CData,
+
+pub fn getWholeText(self: *Text) []const u8 {
+ return self._proto._data;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Text);
+
+ pub const Meta = struct {
+ pub const name = "Text";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const wholeText = bridge.accessor(Text.getWholeText, null, .{});
};
diff --git a/src/browser/webapi/children.zig b/src/browser/webapi/children.zig
new file mode 100644
index 000000000..c0cb1a76f
--- /dev/null
+++ b/src/browser/webapi/children.zig
@@ -0,0 +1,39 @@
+const std = @import("std");
+
+const Node = @import("Node.zig");
+
+const LinkedList = std.DoublyLinkedList;
+
+// Our node._chilren is of type ?*NodeList. The extra (extra) indirection is to
+// keep memory size down.
+// First, a lot of nodes have no children. For these nodes, `?*NodeList = null`
+// will take 8 bytes and require no allocations (because an optional pointer in
+// Zig uses the address 0 to represent null, rather than a separate field).
+// Second, a lot of nodes will have one child. For these nodes, we'll also only
+// use 8 bytes, because @sizeOf(NodeList) == 8. This is the reason the
+// list: *LinkedList is behind a pointer.
+pub const Children = union(enum) {
+ one: *Node,
+ list: *LinkedList,
+
+ pub fn first(self: *const Children) *Node {
+ return switch (self.*) {
+ .one => |n| n,
+ .list => |list| Node.linkToNode(list.first.?),
+ };
+ }
+
+ pub fn last(self: *const Children) *Node {
+ return switch (self.*) {
+ .one => |n| n,
+ .list => |list| Node.linkToNode(list.last.?),
+ };
+ }
+
+ pub fn len(self: *const Children) u32 {
+ return switch (self.*) {
+ .one => 1,
+ .list => |list| @intCast(list.len()),
+ };
+ }
+};
diff --git a/src/browser/webapi/collections.zig b/src/browser/webapi/collections.zig
new file mode 100644
index 000000000..3e36e05f9
--- /dev/null
+++ b/src/browser/webapi/collections.zig
@@ -0,0 +1,41 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pub const NodeLive = @import("collections/node_live.zig").NodeLive;
+pub const ChildNodes = @import("collections/ChildNodes.zig");
+pub const DOMTokenList = @import("collections/DOMTokenList.zig");
+pub const HTMLAllCollection = @import("collections/HTMLAllCollection.zig");
+pub const HTMLOptionsCollection = @import("collections/HTMLOptionsCollection.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ @import("collections/HTMLCollection.zig"),
+ @import("collections/HTMLCollection.zig").Iterator,
+ @import("collections/NodeList.zig"),
+ @import("collections/NodeList.zig").KeyIterator,
+ @import("collections/NodeList.zig").ValueIterator,
+ @import("collections/NodeList.zig").EntryIterator,
+ @import("collections/HTMLAllCollection.zig"),
+ @import("collections/HTMLAllCollection.zig").Iterator,
+ HTMLOptionsCollection,
+ DOMTokenList,
+ DOMTokenList.KeyIterator,
+ DOMTokenList.ValueIterator,
+ DOMTokenList.EntryIterator,
+ };
+}
diff --git a/src/browser/webapi/collections/ChildNodes.zig b/src/browser/webapi/collections/ChildNodes.zig
new file mode 100644
index 000000000..1008d7e9d
--- /dev/null
+++ b/src/browser/webapi/collections/ChildNodes.zig
@@ -0,0 +1,134 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+
+const Node = @import("../Node.zig");
+const Page = @import("../../Page.zig");
+const GenericIterator = @import("iterator.zig").Entry;
+
+// Optimized for node.childNodes, which has to be a live list.
+// No need to go through a TreeWalker or add any filtering.
+const ChildNodes = @This();
+
+_last_index: usize,
+_last_length: ?u32,
+_last_node: ?*std.DoublyLinkedList.Node,
+_cached_version: usize,
+_children: ?*Node.Children,
+
+pub const KeyIterator = GenericIterator(Iterator, "0");
+pub const ValueIterator = GenericIterator(Iterator, "1");
+pub const EntryIterator = GenericIterator(Iterator, null);
+
+pub fn init(children: ?*Node.Children, page: *Page) !*ChildNodes {
+ return page._factory.create(ChildNodes{
+ ._last_index = 0,
+ ._last_node = null,
+ ._last_length = null,
+ ._children = children,
+ ._cached_version = page.version,
+ });
+}
+
+pub fn length(self: *ChildNodes, page: *Page) !u32 {
+ if (self.versionCheck(page)) {
+ if (self._last_length) |cached_length| {
+ return cached_length;
+ }
+ }
+ const children = self._children orelse return 0;
+
+ // O(N)
+ const len = children.len();
+ self._last_length = len;
+ return len;
+}
+
+pub fn getAtIndex(self: *ChildNodes, index: usize, page: *Page) !?*Node {
+ _ = self.versionCheck(page);
+
+ var current = self._last_index;
+ var node: ?*std.DoublyLinkedList.Node = null;
+ if (index <= current) {
+ current = 0;
+ node = self.first() orelse return null;
+ } else {
+ node = self._last_node orelse self.first() orelse return null;
+ }
+ defer self._last_index = current + 1;
+
+ while (node) |n| {
+ if (index == current) {
+ self._last_node = n;
+ return Node.linkToNode(n);
+ }
+ current += 1;
+ node = n.next;
+ }
+ self._last_node = null;
+ return null;
+}
+
+pub fn first(self: *const ChildNodes) ?*std.DoublyLinkedList.Node {
+ return &(self._children orelse return null).first()._child_link;
+}
+
+pub fn keys(self: *ChildNodes, page: *Page) !*KeyIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn values(self: *ChildNodes, page: *Page) !*ValueIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn entries(self: *ChildNodes, page: *Page) !*EntryIterator {
+ return .init(.{ .list = self }, page);
+}
+
+fn versionCheck(self: *ChildNodes, page: *Page) bool {
+ const current = page.version;
+ if (current == self._cached_version) {
+ return true;
+ }
+ self._last_index = 0;
+ self._last_node = null;
+ self._last_length = null;
+ self._cached_version = current;
+ return false;
+}
+
+const NodeList = @import("NodeList.zig");
+pub fn runtimeGenericWrap(self: *ChildNodes, page: *Page) !*NodeList {
+ return page._factory.create(NodeList{ .data = .{ .child_nodes = self } });
+}
+
+const Iterator = struct {
+ index: u32 = 0,
+ list: *ChildNodes,
+
+ const Entry = struct { u32, *Node };
+
+ pub fn next(self: *Iterator, page: *Page) !?Entry {
+ const index = self.index;
+ const node = try self.list.getAtIndex(index, page) orelse return null;
+ self.index = index + 1;
+ return .{ index, node };
+ }
+};
diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig
new file mode 100644
index 000000000..b47ae78dd
--- /dev/null
+++ b/src/browser/webapi/collections/DOMTokenList.zig
@@ -0,0 +1,277 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const log = @import("../../../log.zig");
+const js = @import("../../js/js.zig");
+
+const Page = @import("../../Page.zig");
+const Element = @import("../Element.zig");
+const GenericIterator = @import("iterator.zig").Entry;
+
+pub const DOMTokenList = @This();
+
+// There are a lot of inefficiencies in this code because the list is meant to
+// be live, e.g. reflect changes to the underlying attribute. The only good news
+// is that lists tend to be very short (often just 1 item).
+
+_element: *Element,
+_attribute_name: []const u8,
+
+pub const KeyIterator = GenericIterator(Iterator, "0");
+pub const ValueIterator = GenericIterator(Iterator, "1");
+pub const EntryIterator = GenericIterator(Iterator, null);
+
+const Lookup = std.StringArrayHashMapUnmanaged(void);
+
+const WHITESPACE = " \t\n\r\x0C";
+
+pub fn length(self: *const DOMTokenList, page: *Page) !u32 {
+ const tokens = try self.getTokens(page);
+ return @intCast(tokens.count());
+}
+
+// TODO: soooo..inefficient
+pub fn item(self: *const DOMTokenList, index: usize, page: *Page) !?[]const u8 {
+ var i: usize = 0;
+
+ const allocator = page.call_arena;
+ var seen: std.StringArrayHashMapUnmanaged(void) = .empty;
+
+ var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE);
+ while (it.next()) |token| {
+ const gop = try seen.getOrPut(allocator, token);
+ if (!gop.found_existing) {
+ if (i == index) {
+ return token;
+ }
+ i += 1;
+ }
+ }
+ return null;
+}
+
+pub fn contains(self: *const DOMTokenList, search: []const u8) !bool {
+ var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE);
+ while (it.next()) |token| {
+ if (std.mem.eql(u8, search, token)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+pub fn add(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void {
+ for (tokens) |token| {
+ try validateToken(token);
+ }
+
+ var lookup = try self.getTokens(page);
+ const allocator = page.call_arena;
+ try lookup.ensureUnusedCapacity(allocator, tokens.len);
+
+ for (tokens) |token| {
+ try lookup.put(allocator, token, {});
+ }
+
+ try self.updateAttribute(lookup, page);
+}
+
+pub fn remove(self: *DOMTokenList, tokens: []const []const u8, page: *Page) !void {
+ for (tokens) |token| {
+ try validateToken(token);
+ }
+
+ var lookup = try self.getTokens(page);
+ for (tokens) |token| {
+ _ = lookup.orderedRemove(token);
+ }
+ try self.updateAttribute(lookup, page);
+}
+
+pub fn toggle(self: *DOMTokenList, token: []const u8, force: ?bool, page: *Page) !bool {
+ try validateToken(token);
+
+ const has_token = try self.contains(token);
+
+ if (force) |f| {
+ if (f) {
+ if (!has_token) {
+ const tokens_to_add = [_][]const u8{token};
+ try self.add(&tokens_to_add, page);
+ }
+ return true;
+ } else {
+ if (has_token) {
+ const tokens_to_remove = [_][]const u8{token};
+ try self.remove(&tokens_to_remove, page);
+ }
+ return false;
+ }
+ } else {
+ if (has_token) {
+ const tokens_to_remove = [_][]const u8{token};
+ try self.remove(tokens_to_remove[0..], page);
+ return false;
+ } else {
+ const tokens_to_add = [_][]const u8{token};
+ try self.add(tokens_to_add[0..], page);
+ return true;
+ }
+ }
+}
+
+pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8, page: *Page) !bool {
+ try validateToken(old_token);
+ try validateToken(new_token);
+
+ var lookup = try self.getTokens(page);
+ if (lookup.contains(new_token)) {
+ if (std.mem.eql(u8, new_token, old_token) == false) {
+ _ = lookup.orderedRemove(old_token);
+ try self.updateAttribute(lookup, page);
+ }
+ return true;
+ }
+
+ const key_ptr = lookup.getKeyPtr(old_token) orelse return false;
+ key_ptr.* = new_token;
+ try self.updateAttribute(lookup, page);
+ return true;
+}
+
+pub fn getValue(self: *const DOMTokenList) []const u8 {
+ return self._element.getAttributeSafe(self._attribute_name) orelse "";
+}
+
+pub fn setValue(self: *DOMTokenList, value: []const u8, page: *Page) !void {
+ try self._element.setAttribute(self._attribute_name, value, page);
+}
+
+pub fn keys(self: *DOMTokenList, page: *Page) !*KeyIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn values(self: *DOMTokenList, page: *Page) !*ValueIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn entries(self: *DOMTokenList, page: *Page) !*EntryIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn forEach(self: *DOMTokenList, cb_: js.Function, js_this_: ?js.Object, page: *Page) !void {
+ const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
+
+ const allocator = page.call_arena;
+
+ var i: i32 = 0;
+ var seen: std.StringArrayHashMapUnmanaged(void) = .empty;
+
+ var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE);
+ while (it.next()) |token| {
+ const gop = try seen.getOrPut(allocator, token);
+ if (gop.found_existing) {
+ continue;
+ }
+ var result: js.Function.Result = undefined;
+ cb.tryCall(void, .{ token, i, self }, &result) catch {
+ log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack, .source = "DOMTokenList" });
+ return;
+ };
+ i += 1;
+ }
+}
+
+fn getTokens(self: *const DOMTokenList, page: *Page) !Lookup {
+ const value = self.getValue();
+ if (value.len == 0) {
+ return .empty;
+ }
+
+ var list: Lookup = .empty;
+ const allocator = page.call_arena;
+ try list.ensureTotalCapacity(allocator, 4);
+
+ var it = std.mem.tokenizeAny(u8, value, WHITESPACE);
+ while (it.next()) |token| {
+ try list.put(allocator, token, {});
+ }
+ return list;
+}
+
+fn validateToken(token: []const u8) !void {
+ if (token.len == 0) {
+ return error.SyntaxError;
+ }
+ if (std.mem.indexOfAny(u8, token, &std.ascii.whitespace) != null) {
+ return error.InvalidCharacterError;
+ }
+}
+
+fn updateAttribute(self: *DOMTokenList, tokens: Lookup, page: *Page) !void {
+ const joined = try std.mem.join(page.call_arena, " ", tokens.keys());
+ try self._element.setAttribute(self._attribute_name, joined, page);
+}
+
+const Iterator = struct {
+ index: u32 = 0,
+ list: *DOMTokenList,
+
+ const Entry = struct { u32, []const u8 };
+
+ pub fn next(self: *Iterator, page: *Page) !?Entry {
+ const index = self.index;
+ const node = try self.list.item(index, page) orelse return null;
+ self.index = index + 1;
+ return .{ index, node };
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMTokenList);
+
+ pub const Meta = struct {
+ pub const name = "DOMTokenList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(DOMTokenList.length, null, .{});
+ pub const item = bridge.function(_item, .{});
+ fn _item(self: *const DOMTokenList, index: i32, page: *Page) !?[]const u8 {
+ if (index < 0) {
+ return null;
+ }
+ return self.item(@intCast(index), page);
+ }
+
+ pub const contains = bridge.function(DOMTokenList.contains, .{ .dom_exception = true });
+ pub const add = bridge.function(DOMTokenList.add, .{ .dom_exception = true });
+ pub const remove = bridge.function(DOMTokenList.remove, .{ .dom_exception = true });
+ pub const toggle = bridge.function(DOMTokenList.toggle, .{ .dom_exception = true });
+ pub const replace = bridge.function(DOMTokenList.replace, .{ .dom_exception = true });
+ pub const value = bridge.accessor(DOMTokenList.getValue, DOMTokenList.setValue, .{});
+ pub const toString = bridge.function(DOMTokenList.getValue, .{});
+ pub const keys = bridge.function(DOMTokenList.keys, .{});
+ pub const values = bridge.function(DOMTokenList.values, .{});
+ pub const entries = bridge.function(DOMTokenList.entries, .{});
+ pub const symbol_iterator = bridge.iterator(DOMTokenList.values, .{});
+ pub const forEach = bridge.function(DOMTokenList.forEach, .{});
+ pub const @"[]" = bridge.indexed(DOMTokenList.item, .{ .null_as_undefined = true });
+};
diff --git a/src/browser/webapi/collections/HTMLAllCollection.zig b/src/browser/webapi/collections/HTMLAllCollection.zig
new file mode 100644
index 000000000..f781986d3
--- /dev/null
+++ b/src/browser/webapi/collections/HTMLAllCollection.zig
@@ -0,0 +1,187 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+const TreeWalker = @import("../TreeWalker.zig");
+
+const HTMLAllCollection = @This();
+
+_tw: TreeWalker.FullExcludeSelf,
+_last_index: usize,
+_last_length: ?u32,
+_cached_version: usize,
+
+pub fn init(root: *Node, page: *Page) HTMLAllCollection {
+ return .{
+ ._last_index = 0,
+ ._last_length = null,
+ ._tw = TreeWalker.FullExcludeSelf.init(root, .{}),
+ ._cached_version = page.version,
+ };
+}
+
+fn versionCheck(self: *HTMLAllCollection, page: *const Page) bool {
+ if (self._cached_version != page.version) {
+ self._cached_version = page.version;
+ self._last_index = 0;
+ self._last_length = null;
+ self._tw.reset();
+ return false;
+ }
+ return true;
+}
+
+pub fn length(self: *HTMLAllCollection, page: *const Page) u32 {
+ if (self.versionCheck(page)) {
+ if (self._last_length) |cached_length| {
+ return cached_length;
+ }
+ }
+
+ std.debug.assert(self._last_index == 0);
+
+ var tw = &self._tw;
+ defer tw.reset();
+
+ var l: u32 = 0;
+ while (tw.next()) |node| {
+ if (node.is(Element) != null) {
+ l += 1;
+ }
+ }
+
+ self._last_length = l;
+ return l;
+}
+
+pub fn getAtIndex(self: *HTMLAllCollection, index: usize, page: *const Page) ?*Element {
+ _ = self.versionCheck(page);
+ var current = self._last_index;
+ if (index <= current) {
+ current = 0;
+ self._tw.reset();
+ }
+ defer self._last_index = current + 1;
+
+ const tw = &self._tw;
+ while (tw.next()) |node| {
+ if (node.is(Element)) |el| {
+ if (index == current) {
+ return el;
+ }
+ current += 1;
+ }
+ }
+
+ return null;
+}
+
+pub fn getByName(self: *HTMLAllCollection, name: []const u8, page: *Page) ?*Element {
+ // First, try fast ID lookup using the document's element map
+ if (page.document._elements_by_id.get(name)) |el| {
+ return el;
+ }
+
+ // Fall back to searching by name attribute
+ // Clone the tree walker to preserve _last_index optimization
+ _ = self.versionCheck(page);
+ var tw = self._tw.clone();
+ tw.reset();
+
+ while (tw.next()) |node| {
+ if (node.is(Element)) |el| {
+ if (el.getAttributeSafe("name")) |attr_name| {
+ if (std.mem.eql(u8, attr_name, name)) {
+ return el;
+ }
+ }
+ }
+ }
+
+ return null;
+}
+
+const CAllAsFunctionArg = union(enum) {
+ index: u32,
+ id: []const u8,
+};
+pub fn callable(self: *HTMLAllCollection, arg: CAllAsFunctionArg, page: *Page) ?*Element {
+ return switch (arg) {
+ .index => |i| self.getAtIndex(i, page),
+ .id => |id| self.getByName(id, page),
+ };
+}
+
+pub fn iterator(self: *HTMLAllCollection, page: *Page) !*Iterator {
+ return Iterator.init(.{
+ .list = self,
+ .tw = self._tw.clone(),
+ }, page);
+}
+
+const GenericIterator = @import("iterator.zig").Entry;
+pub const Iterator = GenericIterator(struct {
+ list: *HTMLAllCollection,
+ tw: TreeWalker.FullExcludeSelf,
+
+ pub fn next(self: *@This(), _: *Page) ?*Element {
+ while (self.tw.next()) |node| {
+ if (node.is(Element)) |el| {
+ return el;
+ }
+ }
+ return null;
+ }
+}, null);
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(HTMLAllCollection);
+
+ pub const Meta = struct {
+ pub const name = "HTMLAllCollection";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+
+ // This is a very weird class that requires special JavaScript behavior
+ // this htmldda and callable are only used here..
+ pub const htmldda = true;
+ pub const callable = JsApi.callable;
+ };
+
+ pub const length = bridge.accessor(HTMLAllCollection.length, null, .{});
+ pub const @"[int]" = bridge.indexed(HTMLAllCollection.getAtIndex, .{ .null_as_undefined = true });
+ pub const @"[str]" = bridge.namedIndexed(HTMLAllCollection.getByName, null, null, .{ .null_as_undefined = true });
+
+ pub const item = bridge.function(_item, .{});
+ fn _item(self: *HTMLAllCollection, index: i32, page: *Page) ?*Element {
+ if (index < 0) {
+ return null;
+ }
+ return self.getAtIndex(@intCast(index), page);
+ }
+
+ pub const namedItem = bridge.function(HTMLAllCollection.getByName, .{});
+ pub const symbol_iterator = bridge.iterator(HTMLAllCollection.iterator, .{});
+
+ pub const callable = bridge.callable(HTMLAllCollection.callable, .{ .null_as_undefined = true });
+};
diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig
new file mode 100644
index 000000000..3160524da
--- /dev/null
+++ b/src/browser/webapi/collections/HTMLCollection.zig
@@ -0,0 +1,146 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const Element = @import("../Element.zig");
+const TreeWalker = @import("../TreeWalker.zig");
+const NodeLive = @import("node_live.zig").NodeLive;
+
+const Mode = enum {
+ tag,
+ tag_name,
+ class_name,
+ name,
+ all_elements,
+ child_elements,
+ child_tag,
+ selected_options,
+ links,
+ anchors,
+};
+
+const HTMLCollection = @This();
+
+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),
+ selected_options: NodeLive(.selected_options),
+ links: NodeLive(.links),
+ anchors: NodeLive(.anchors),
+},
+
+pub fn length(self: *HTMLCollection, page: *const Page) u32 {
+ return switch (self.data) {
+ inline else => |*impl| impl.length(page),
+ };
+}
+
+pub fn getAtIndex(self: *HTMLCollection, index: usize, page: *const Page) ?*Element {
+ return switch (self.data) {
+ inline else => |*impl| impl.getAtIndex(index, page),
+ };
+}
+
+pub fn getByName(self: *HTMLCollection, name: []const u8, page: *Page) ?*Element {
+ return switch (self.data) {
+ inline else => |*impl| impl.getByName(name, page),
+ };
+}
+
+pub fn iterator(self: *HTMLCollection, page: *Page) !*Iterator {
+ return Iterator.init(.{
+ .list = self,
+ .tw = switch (self.data) {
+ .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() },
+ .selected_options => |*impl| .{ .selected_options = impl._tw.clone() },
+ .links => |*impl| .{ .links = impl._tw.clone() },
+ .anchors => |*impl| .{ .anchors = impl._tw.clone() },
+ },
+ }, page);
+}
+
+const GenericIterator = @import("iterator.zig").Entry;
+pub const Iterator = GenericIterator(struct {
+ list: *HTMLCollection,
+ tw: union(Mode) {
+ 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,
+ selected_options: TreeWalker.Children,
+ links: TreeWalker.FullExcludeSelf,
+ anchors: TreeWalker.FullExcludeSelf,
+ },
+
+ pub fn next(self: *@This(), _: *Page) ?*Element {
+ return switch (self.list.data) {
+ .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),
+ .selected_options => |*impl| impl.nextTw(&self.tw.selected_options),
+ .links => |*impl| impl.nextTw(&self.tw.links),
+ .anchors => |*impl| impl.nextTw(&self.tw.anchors),
+ };
+ }
+}, null);
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(HTMLCollection);
+
+ pub const Meta = struct {
+ pub const name = "HTMLCollection";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(HTMLCollection.length, null, .{});
+ pub const @"[int]" = bridge.indexed(HTMLCollection.getAtIndex, .{ .null_as_undefined = true });
+ pub const @"[str]" = bridge.namedIndexed(HTMLCollection.getByName, null, null, .{ .null_as_undefined = true });
+
+ pub const item = bridge.function(_item, .{});
+ fn _item(self: *HTMLCollection, index: i32, page: *Page) ?*Element {
+ if (index < 0) {
+ return null;
+ }
+ return self.getAtIndex(@intCast(index), page);
+ }
+
+ pub const namedItem = bridge.function(HTMLCollection.getByName, .{});
+ pub const symbol_iterator = bridge.iterator(HTMLCollection.iterator, .{});
+};
diff --git a/src/browser/webapi/collections/HTMLOptionsCollection.zig b/src/browser/webapi/collections/HTMLOptionsCollection.zig
new file mode 100644
index 000000000..4474cb761
--- /dev/null
+++ b/src/browser/webapi/collections/HTMLOptionsCollection.zig
@@ -0,0 +1,112 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+const HTMLCollection = @import("HTMLCollection.zig");
+const NodeLive = @import("node_live.zig").NodeLive;
+
+const HTMLOptionsCollection = @This();
+
+_proto: *HTMLCollection,
+_select: *@import("../element/html/Select.zig"),
+
+// Forward length to HTMLCollection
+pub fn length(self: *HTMLOptionsCollection, page: *Page) u32 {
+ return self._proto.length(page);
+}
+
+// Forward indexed access to HTMLCollection
+pub fn getAtIndex(self: *HTMLOptionsCollection, index: usize, page: *Page) ?*Element {
+ return self._proto.getAtIndex(index, page);
+}
+
+pub fn getByName(self: *HTMLOptionsCollection, name: []const u8, page: *Page) ?*Element {
+ return self._proto.getByName(name, page);
+}
+
+// Forward selectedIndex to the owning select element
+pub fn getSelectedIndex(self: *const HTMLOptionsCollection) i32 {
+ return self._select.getSelectedIndex();
+}
+
+pub fn setSelectedIndex(self: *HTMLOptionsCollection, index: i32) !void {
+ return self._select.setSelectedIndex(index);
+}
+
+const Option = @import("../element/html/Option.zig");
+
+const AddBeforeOption = union(enum) {
+ option: *Option,
+ index: u32,
+};
+
+// Add a new option element
+pub fn add(self: *HTMLOptionsCollection, element: *Option, before_: ?AddBeforeOption, page: *Page) !void {
+ const select_node = self._select.asNode();
+ const element_node = element.asElement().asNode();
+
+ var before_node: ?*Node = null;
+ if (before_) |before| {
+ switch (before) {
+ .index => |idx| {
+ if (self.getAtIndex(idx, page)) |el| {
+ before_node = el.asNode();
+ }
+ },
+ .option => |before_option| before_node = before_option.asNode(),
+ }
+ }
+ _ = try select_node.insertBefore(element_node, before_node, page);
+}
+
+// Remove an option element by index
+pub fn remove(self: *HTMLOptionsCollection, index: i32, page: *Page) void {
+ if (index < 0) {
+ return;
+ }
+
+ if (self._proto.getAtIndex(@intCast(index), page)) |element| {
+ element.remove(page);
+ }
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(HTMLOptionsCollection);
+
+ pub const Meta = struct {
+ pub const name = "HTMLOptionsCollection";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ pub const manage = false;
+ };
+
+ pub const length = bridge.accessor(HTMLOptionsCollection.length, null, .{});
+
+ // Indexed access
+ pub const @"[int]" = bridge.indexed(HTMLOptionsCollection.getAtIndex, .{ .null_as_undefined = true });
+ pub const @"[str]" = bridge.namedIndexed(HTMLOptionsCollection.getByName, null, null, .{ .null_as_undefined = true });
+
+ pub const selectedIndex = bridge.accessor(HTMLOptionsCollection.getSelectedIndex, HTMLOptionsCollection.setSelectedIndex, .{});
+ pub const add = bridge.function(HTMLOptionsCollection.add, .{});
+ pub const remove = bridge.function(HTMLOptionsCollection.remove, .{});
+};
diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig
new file mode 100644
index 000000000..8ee8b104c
--- /dev/null
+++ b/src/browser/webapi/collections/NodeList.zig
@@ -0,0 +1,120 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+
+const log = @import("../../..//log.zig");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const Node = @import("../Node.zig");
+
+const ChildNodes = @import("ChildNodes.zig");
+const SelectorList = @import("../selector/List.zig");
+
+const Mode = enum {
+ child_nodes,
+ selector_list,
+};
+
+const NodeList = @This();
+
+data: union(Mode) {
+ child_nodes: *ChildNodes,
+ selector_list: *SelectorList,
+},
+
+pub fn length(self: *NodeList, page: *Page) !u32 {
+ return switch (self.data) {
+ .child_nodes => |impl| impl.length(page),
+ .selector_list => |impl| @intCast(impl.getLength()),
+ };
+}
+
+pub fn getAtIndex(self: *NodeList, index: usize, page: *Page) !?*Node {
+ return switch (self.data) {
+ .child_nodes => |impl| impl.getAtIndex(index, page),
+ .selector_list => |impl| impl.getAtIndex(index),
+ };
+}
+
+pub fn keys(self: *NodeList, page: *Page) !*KeyIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn values(self: *NodeList, page: *Page) !*ValueIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn entries(self: *NodeList, page: *Page) !*EntryIterator {
+ return .init(.{ .list = self }, page);
+}
+
+pub fn forEach(self: *NodeList, cb: js.Function, page: *Page) !void {
+ var i: i32 = 0;
+ var it = try self.values(page);
+ while (true) : (i += 1) {
+ const next = try it.next(page);
+ if (next.done) {
+ return;
+ }
+
+ var result: js.Function.Result = undefined;
+ cb.tryCall(void, .{ next.value, i, self }, &result) catch {
+ log.debug(.js, "forEach callback", .{ .err = result.exception, .stack = result.stack, .source = "nodelist" });
+ return;
+ };
+ }
+}
+
+const GenericIterator = @import("iterator.zig").Entry;
+pub const KeyIterator = GenericIterator(Iterator, "0");
+pub const ValueIterator = GenericIterator(Iterator, "1");
+pub const EntryIterator = GenericIterator(Iterator, null);
+
+const Iterator = struct {
+ index: u32 = 0,
+ list: *NodeList,
+
+ const Entry = struct { u32, *Node };
+
+ pub fn next(self: *Iterator, page: *Page) !?Entry {
+ const index = self.index;
+ const node = try self.list.getAtIndex(index, page) orelse return null;
+ self.index = index + 1;
+ return .{ index, node };
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(NodeList);
+
+ pub const Meta = struct {
+ pub const name = "NodeList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(NodeList.length, null, .{});
+ pub const @"[]" = bridge.indexed(NodeList.getAtIndex, .{ .null_as_undefined = true });
+ pub const item = bridge.function(NodeList.getAtIndex, .{});
+ pub const keys = bridge.function(NodeList.keys, .{});
+ pub const values = bridge.function(NodeList.values, .{});
+ pub const entries = bridge.function(NodeList.entries, .{});
+ pub const forEach = bridge.function(NodeList.forEach, .{});
+ pub const symbol_iterator = bridge.iterator(NodeList.values, .{});
+};
diff --git a/src/browser/webapi/collections/iterator.zig b/src/browser/webapi/collections/iterator.zig
new file mode 100644
index 000000000..0c063e846
--- /dev/null
+++ b/src/browser/webapi/collections/iterator.zig
@@ -0,0 +1,105 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+pub fn Entry(comptime Inner: type, comptime field: ?[]const u8) type {
+ const InnerStruct = Inner;
+ const R = reflect(InnerStruct, field);
+
+ return struct {
+ inner: Inner,
+
+ const Self = @This();
+
+ const Result = struct {
+ done: bool,
+ value: ?R.ValueType,
+
+ pub const js_as_object = true;
+ };
+
+ pub fn init(inner: Inner, page: *Page) !*Self {
+ return page._factory.create(Self{ .inner = inner });
+ }
+
+ pub fn next(self: *Self, page: *Page) if (R.has_error_return) anyerror!Result else Result {
+ const entry = (if (comptime R.has_error_return) try self.inner.next(page) else self.inner.next(page)) orelse {
+ return .{ .done = true, .value = null };
+ };
+
+ if (comptime field == null) {
+ return .{ .done = false, .value = entry };
+ }
+
+ return .{
+ .done = false,
+ .value = @field(entry, field.?),
+ };
+ }
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(Self);
+
+ pub const Meta = struct {
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const next = bridge.function(Self.next, .{ .null_as_undefined = true });
+ pub const symbol_iterator = bridge.iterator(Self, .{});
+ };
+ };
+}
+
+fn reflect(comptime Inner: type, comptime field: ?[]const u8) Reflect {
+ const R = @typeInfo(@TypeOf(Inner.next)).@"fn".return_type.?;
+ const has_error_return = @typeInfo(R) == .error_union;
+ return .{
+ .has_error_return = has_error_return,
+ .ValueType = ValueType(unwrapOptional(unwrapError(R)), field),
+ };
+}
+
+const Reflect = struct {
+ has_error_return: bool,
+ ValueType: type,
+};
+
+fn unwrapError(comptime T: type) type {
+ if (@typeInfo(T) == .error_union) {
+ return @typeInfo(T).error_union.payload;
+ }
+ return T;
+}
+
+fn unwrapOptional(comptime T: type) type {
+ return @typeInfo(T).optional.child;
+}
+
+fn ValueType(comptime R: type, comptime field_: ?[]const u8) type {
+ const field = field_ orelse return R;
+ inline for (@typeInfo(R).@"struct".fields) |f| {
+ if (comptime std.mem.eql(u8, f.name, field)) {
+ return f.type;
+ }
+ }
+ @compileError("Unknown EntryIterator field " ++ @typeName(R) ++ "." ++ field);
+}
diff --git a/src/browser/webapi/collections/node_live.zig b/src/browser/webapi/collections/node_live.zig
new file mode 100644
index 000000000..f3f4dd1a1
--- /dev/null
+++ b/src/browser/webapi/collections/node_live.zig
@@ -0,0 +1,295 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const builtin = @import("builtin");
+
+const String = @import("../../../string.zig").String;
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+const TreeWalker = @import("../TreeWalker.zig");
+const Selector = @import("../selector/Selector.zig");
+
+const Allocator = std.mem.Allocator;
+
+const Mode = enum {
+ tag,
+ tag_name,
+ class_name,
+ name,
+ all_elements,
+ child_elements,
+ child_tag,
+ selected_options,
+ links,
+ anchors,
+};
+
+const Filters = union(Mode) {
+ tag: Element.Tag,
+ tag_name: String,
+ class_name: [][]const u8,
+ name: []const u8,
+ all_elements,
+ child_elements,
+ child_tag: Element.Tag,
+ selected_options,
+ links,
+ anchors,
+
+ fn TypeOf(comptime mode: Mode) type {
+ @setEvalBranchQuota(2000);
+ return std.meta.fieldInfo(Filters, mode).type;
+ }
+};
+
+// Operations on the live DOM can be inefficient. Do we really have to walk
+// through the entire tree, filtering out elements we don't care about, every
+// time .length is called?
+// To improve this, we track the "version" of the DOM (root.version). If the
+// version changes between operations, than we have to restart and pay the full
+// price.
+// But, if the version hasn't changed, then we can leverage other stateful data
+// to improve performance. For example, we cache the length property. So once
+// we've walked the tree to figure the length, we can re-use the cached property
+// if the DOM is unchanged (i.e. if our _cached_version == page.version).
+//
+// We do something similar for indexed getter (e.g. coll[4]), by preserving the
+// last node visited in the tree (implicitly by not resetting the TreeWalker).
+// If the DOM version is unchanged and the new index >= the last one, we can do
+// not have to reset our TreeWalker. This optimizes the common case of accessing
+// the collection via incrementing indexes.
+
+pub fn NodeLive(comptime mode: Mode) type {
+ const Filter = Filters.TypeOf(mode);
+ const TW = switch (mode) {
+ .tag, .tag_name, .class_name, .name, .all_elements, .links, .anchors => TreeWalker.FullExcludeSelf,
+ .child_elements, .child_tag, .selected_options => TreeWalker.Children,
+ };
+ return struct {
+ _tw: TW,
+ _filter: Filter,
+ _last_index: usize,
+ _last_length: ?u32,
+ _cached_version: usize,
+
+ const Self = @This();
+
+ pub fn init(root: *Node, filter: Filter, page: *Page) Self {
+ return .{
+ ._last_index = 0,
+ ._last_length = null,
+ ._filter = filter,
+ ._tw = TW.init(root, .{}),
+ ._cached_version = page.version,
+ };
+ }
+
+ pub fn length(self: *Self, page: *const Page) u32 {
+ if (self.versionCheck(page)) {
+ // the DOM version hasn't changed, use the cached version if
+ // we have one
+ if (self._last_length) |cached_length| {
+ return cached_length;
+ }
+ }
+
+ // If we're here, it means it's either the first time we're called
+ // or the DOM version has changed. Either way, the _tw should be
+ // at the start position. It's important that self._last_index == 0
+ // (which it always should be in these cases), because we're going to
+ // reset _tw at the end of this, _last_index should always be 0 when
+ // _tw is reset. Again, this should always be the case, but we're
+ // asserting to make sure, else we'll have weird behavior, namely
+ // the wrong item being returned for the wrong index.
+ std.debug.assert(self._last_index == 0);
+
+ var tw = &self._tw;
+ defer tw.reset();
+
+ var l: u32 = 0;
+ while (self.nextTw(tw)) |_| {
+ l += 1;
+ }
+
+ self._last_length = l;
+ return l;
+ }
+
+ // This API supports indexing by both numeric index and id/name
+ // i.e. a combination of getAtIndex and getByName
+ pub fn getIndexed(self: *Self, value: js.Atom, page: *Page) !?*Element {
+ if (value.isUint()) |n| {
+ return self.getAtIndex(n, page);
+ }
+
+ const name = value.toString();
+ defer value.freeString(name);
+
+ return self.getByName(name, page) orelse return error.NotHandled;
+ }
+
+ pub fn getAtIndex(self: *Self, index: usize, page: *const Page) ?*Element {
+ _ = self.versionCheck(page);
+ var current = self._last_index;
+ if (index <= current) {
+ current = 0;
+ self._tw.reset();
+ }
+ defer self._last_index = current + 1;
+
+ const tw = &self._tw;
+ while (self.nextTw(tw)) |el| {
+ if (index == current) {
+ return el;
+ }
+ current += 1;
+ }
+ return null;
+ }
+
+ pub fn getByName(self: *Self, name: []const u8, page: *Page) ?*Element {
+ if (page.document.getElementById(name)) |element| {
+ const node = element.asNode();
+ if (self._tw.contains(node) and self.matches(node)) {
+ return element;
+ }
+ }
+
+ // Element not found by id, fallback to search by name. This isn't
+ // efficient!
+
+ // Gives us a TreeWalker based on the original, but reset to the
+ // root. Doing this preserves any cache data we have for other calls
+ // (like length or getAtIndex)
+ var tw = self._tw.clone();
+ while (self.nextTw(&tw)) |element| {
+ const element_name = element.getAttributeSafe("name") orelse continue;
+ if (std.mem.eql(u8, element_name, name)) {
+ return element;
+ }
+ }
+ return null;
+ }
+
+ pub fn nextTw(self: *Self, tw: *TW) ?*Element {
+ while (tw.next()) |node| {
+ if (self.matches(node)) {
+ return node.as(Element);
+ }
+ }
+ return null;
+ }
+
+ fn matches(self: *const Self, node: *Node) bool {
+ switch (mode) {
+ .tag => {
+ const el = node.is(Element) orelse return false;
+ return el.getTag() == self._filter;
+ },
+ .tag_name => {
+ // If we're in `tag_name` mode, then the tag_name isn't
+ // a known tag. It could be a custom element, heading, or
+ // any generic element. Compare against the element's tag name.
+ const el = node.is(Element) orelse return false;
+ const element_tag = el.getTagNameLower();
+ return std.mem.eql(u8, element_tag, self._filter.str());
+ },
+ .class_name => {
+ if (self._filter.len == 0) {
+ return false;
+ }
+
+ const el = node.is(Element) orelse return false;
+ const class_attr = el.getAttributeSafe("class") orelse return false;
+ for (self._filter) |class_name| {
+ if (!Selector.classAttributeContains(class_attr, class_name)) {
+ return false;
+ }
+ }
+ return true;
+ },
+ .name => {
+ const el = node.is(Element) orelse return false;
+ const name_attr = el.getAttributeSafe("name") orelse return false;
+ return std.mem.eql(u8, name_attr, self._filter);
+ },
+ .all_elements => return node._type == .element,
+ .child_elements => return node._type == .element,
+ .child_tag => {
+ const el = node.is(Element) orelse return false;
+ return el.getTag() == self._filter;
+ },
+ .selected_options => {
+ const el = node.is(Element) orelse return false;
+ const Option = Element.Html.Option;
+ const opt = el.is(Option) orelse return false;
+ return opt.getSelected();
+ },
+ .links => {
+ // Links are elements with href attribute (TODO: also when implemented)
+ const el = node.is(Element) orelse return false;
+ const Anchor = Element.Html.Anchor;
+ if (el.is(Anchor) == null) return false;
+ return el.hasAttributeSafe("href");
+ },
+ .anchors => {
+ // Anchors are elements with name attribute
+ const el = node.is(Element) orelse return false;
+ const Anchor = Element.Html.Anchor;
+ if (el.is(Anchor) == null) return false;
+ return el.hasAttributeSafe("name");
+ },
+ }
+ }
+
+ fn versionCheck(self: *Self, page: *const Page) bool {
+ const current = page.version;
+ if (current == self._cached_version) {
+ return true;
+ }
+
+ self._tw.reset();
+ self._last_index = 0;
+ self._last_length = null;
+ self._cached_version = current;
+ return false;
+ }
+
+ const HTMLCollection = @import("HTMLCollection.zig");
+ pub fn runtimeGenericWrap(self: Self, page: *Page) !*HTMLCollection {
+ const collection = switch (mode) {
+ .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 } },
+ .selected_options => HTMLCollection{ .data = .{ .selected_options = self } },
+ .links => HTMLCollection{ .data = .{ .links = self } },
+ .anchors => HTMLCollection{ .data = .{ .anchors = self } },
+ };
+ return page._factory.create(collection);
+ }
+ };
+}
diff --git a/src/browser/webapi/css/CSSRule.zig b/src/browser/webapi/css/CSSRule.zig
new file mode 100644
index 000000000..dcf41db9e
--- /dev/null
+++ b/src/browser/webapi/css/CSSRule.zig
@@ -0,0 +1,90 @@
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+
+const CSSRule = @This();
+
+pub const Type = enum(u16) {
+ style = 1,
+ charset = 2,
+ import = 3,
+ media = 4,
+ font_face = 5,
+ page = 6,
+ keyframes = 7,
+ keyframe = 8,
+ margin = 9,
+ namespace = 10,
+ counter_style = 11,
+ supports = 12,
+ document = 13,
+ font_feature_values = 14,
+ viewport = 15,
+ region_style = 16,
+};
+
+_type: Type,
+
+pub fn init(rule_type: Type, page: *Page) !*CSSRule {
+ return page._factory.create(CSSRule{
+ ._type = rule_type,
+ });
+}
+
+pub fn getType(self: *const CSSRule) u16 {
+ return @intFromEnum(self._type);
+}
+
+pub fn getCssText(self: *const CSSRule, page: *Page) []const u8 {
+ _ = self;
+ _ = page;
+ return "";
+}
+
+pub fn setCssText(self: *CSSRule, text: []const u8, page: *Page) !void {
+ _ = self;
+ _ = text;
+ _ = page;
+}
+
+pub fn getParentRule(self: *const CSSRule) ?*CSSRule {
+ _ = self;
+ return null;
+}
+
+pub fn getParentStyleSheet(self: *const CSSRule) ?*CSSRule {
+ _ = self;
+ return null;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSRule);
+
+ pub const Meta = struct {
+ pub const name = "CSSRule";
+ pub var class_id: bridge.ClassId = undefined;
+ pub const prototype_chain = bridge.prototypeChain();
+ };
+
+ pub const STYLE_RULE = 1;
+ pub const CHARSET_RULE = 2;
+ pub const IMPORT_RULE = 3;
+ pub const MEDIA_RULE = 4;
+ pub const FONT_FACE_RULE = 5;
+ pub const PAGE_RULE = 6;
+ pub const KEYFRAMES_RULE = 7;
+ pub const KEYFRAME_RULE = 8;
+ pub const MARGIN_RULE = 9;
+ pub const NAMESPACE_RULE = 10;
+ pub const COUNTER_STYLE_RULE = 11;
+ pub const SUPPORTS_RULE = 12;
+ pub const DOCUMENT_RULE = 13;
+ pub const FONT_FEATURE_VALUES_RULE = 14;
+ pub const VIEWPORT_RULE = 15;
+ pub const REGION_STYLE_RULE = 16;
+
+ pub const @"type" = bridge.accessor(CSSRule.getType, null, .{});
+ pub const cssText = bridge.accessor(CSSRule.getCssText, CSSRule.setCssText, .{});
+ pub const parentRule = bridge.accessor(CSSRule.getParentRule, null, .{});
+ pub const parentStyleSheet = bridge.accessor(CSSRule.getParentStyleSheet, null, .{});
+};
diff --git a/src/browser/webapi/css/CSSRuleList.zig b/src/browser/webapi/css/CSSRuleList.zig
new file mode 100644
index 000000000..4a700237c
--- /dev/null
+++ b/src/browser/webapi/css/CSSRuleList.zig
@@ -0,0 +1,36 @@
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const CSSRule = @import("CSSRule.zig");
+
+const CSSRuleList = @This();
+
+_rules: []*CSSRule = &.{},
+
+pub fn init(page: *Page) !*CSSRuleList {
+ return page._factory.create(CSSRuleList{});
+}
+
+pub fn length(self: *const CSSRuleList) u32 {
+ return @intCast(self._rules.len);
+}
+
+pub fn item(self: *const CSSRuleList, index: usize) ?*CSSRule {
+ if (index >= self._rules.len) {
+ return null;
+ }
+ return self._rules[index];
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSRuleList);
+
+ pub const Meta = struct {
+ pub const name = "CSSRuleList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(CSSRuleList.length, null, .{});
+ pub const @"[]" = bridge.indexed(CSSRuleList.item, .{ .null_as_undefined = true });
+};
diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig
new file mode 100644
index 000000000..536fa7376
--- /dev/null
+++ b/src/browser/webapi/css/CSSStyleDeclaration.zig
@@ -0,0 +1,241 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const log = @import("../../../log.zig");
+const String = @import("../../../string.zig").String;
+
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const Element = @import("../Element.zig");
+
+const CSSStyleDeclaration = @This();
+
+_element: ?*Element = null,
+_properties: std.DoublyLinkedList = .{},
+
+pub fn init(element: ?*Element, page: *Page) !*CSSStyleDeclaration {
+ return page._factory.create(CSSStyleDeclaration{
+ ._element = element,
+ });
+}
+
+pub fn length(self: *const CSSStyleDeclaration) u32 {
+ return @intCast(self._properties.len());
+}
+
+pub fn item(self: *const CSSStyleDeclaration, index: u32) []const u8 {
+ var i: u32 = 0;
+ var node = self._properties.first;
+ while (node) |n| {
+ if (i == index) {
+ const prop = Property.fromNodeLink(n);
+ return prop._name.str();
+ }
+ i += 1;
+ node = n.next;
+ }
+ return "";
+}
+
+pub fn getPropertyValue(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
+ const normalized = normalizePropertyName(property_name, &page.buf);
+ const prop = self.findProperty(normalized) orelse return "";
+ return prop._value.str();
+}
+
+pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []const u8, page: *Page) []const u8 {
+ const normalized = normalizePropertyName(property_name, &page.buf);
+ const prop = self.findProperty(normalized) orelse return "";
+ return if (prop._important) "important" else "";
+}
+
+pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, priority_: ?[]const u8, page: *Page) !void {
+ if (value.len == 0) {
+ _ = try self.removeProperty(property_name, page);
+ return;
+ }
+
+ const normalized = normalizePropertyName(property_name, &page.buf);
+ const priority = priority_ orelse "";
+
+ // Validate priority
+ const important = if (priority.len > 0) blk: {
+ if (!std.mem.eql(u8, priority, "important")) {
+ return;
+ }
+ break :blk true;
+ } else false;
+
+ // Find existing property
+ if (self.findProperty(normalized)) |existing| {
+ existing._value = try String.init(page.arena, value, .{});
+ existing._important = important;
+ return;
+ }
+
+ // Create new property
+ const prop = try page._factory.create(Property{
+ ._node = .{},
+ ._name = try String.init(page.arena, normalized, .{}),
+ ._value = try String.init(page.arena, value, .{}),
+ ._important = important,
+ });
+ self._properties.append(&prop._node);
+}
+
+pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, page: *Page) ![]const u8 {
+ const normalized = normalizePropertyName(property_name, &page.buf);
+ const prop = self.findProperty(normalized) orelse return "";
+
+ // the value might not be on the heap (it could be inlined in the small string
+ // optimization), so we need to dupe it.
+ const old_value = try page.call_arena.dupe(u8, prop._value.str());
+ self._properties.remove(&prop._node);
+ page._factory.destroy(prop);
+ return old_value;
+}
+
+pub fn getCssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 {
+ if (self._element == null) return "";
+
+ var buf = std.Io.Writer.Allocating.init(page.call_arena);
+ try self.format(&buf.writer);
+ return buf.written();
+}
+
+pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, page: *Page) !void {
+ if (self._element == null) return;
+
+ // Clear existing properties
+ var node = self._properties.first;
+ while (node) |n| {
+ const next = n.next;
+ const prop = Property.fromNodeLink(n);
+ self._properties.remove(n);
+ page._factory.destroy(prop);
+ node = next;
+ }
+
+ // 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, ';');
+ 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);
+ }
+ }
+}
+
+pub fn format(self: *const CSSStyleDeclaration, writer: *std.Io.Writer) !void {
+ const node = self._properties.first orelse return;
+ try Property.fromNodeLink(node).format(writer);
+
+ var next = node.next;
+ while (next) |n| {
+ try writer.writeByte(' ');
+ try Property.fromNodeLink(n).format(writer);
+ next = n.next;
+ }
+}
+
+fn findProperty(self: *const CSSStyleDeclaration, name: []const u8) ?*Property {
+ var node = self._properties.first;
+ while (node) |n| {
+ const prop = Property.fromNodeLink(n);
+ if (prop._name.eqlSlice(name)) {
+ return prop;
+ }
+ node = n.next;
+ }
+ return null;
+}
+
+fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 {
+ if (name.len > buf.len) {
+ log.info(.dom, "css.long.name", .{ .name = name });
+ return name;
+ }
+ return std.ascii.lowerString(buf, name);
+}
+
+pub const Property = struct {
+ _name: String,
+ _value: String,
+ _important: bool = false,
+ _node: std.DoublyLinkedList.Node,
+
+ fn fromNodeLink(n: *std.DoublyLinkedList.Node) *Property {
+ return @alignCast(@fieldParentPtr("_node", n));
+ }
+
+ pub fn format(self: *const Property, writer: *std.Io.Writer) !void {
+ try self._name.format(writer);
+ try writer.writeAll(": ");
+ try self._value.format(writer);
+
+ if (self._important) {
+ try writer.writeAll(" !important");
+ }
+ try writer.writeByte(';');
+ }
+};
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSStyleDeclaration);
+
+ pub const Meta = struct {
+ pub const name = "CSSStyleDeclaration";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const cssText = bridge.accessor(CSSStyleDeclaration.getCssText, CSSStyleDeclaration.setCssText, .{});
+ pub const length = bridge.accessor(CSSStyleDeclaration.length, null, .{});
+ pub const item = bridge.function(_item, .{});
+
+ fn _item(self: *const CSSStyleDeclaration, index: i32) []const u8 {
+ if (index < 0) {
+ return "";
+ }
+ return self.item(@intCast(index));
+ }
+
+ pub const getPropertyValue = bridge.function(CSSStyleDeclaration.getPropertyValue, .{});
+ pub const getPropertyPriority = bridge.function(CSSStyleDeclaration.getPropertyPriority, .{});
+ pub const setProperty = bridge.function(CSSStyleDeclaration.setProperty, .{});
+ pub const removeProperty = bridge.function(CSSStyleDeclaration.removeProperty, .{});
+};
diff --git a/src/browser/webapi/css/CSSStyleProperties.zig b/src/browser/webapi/css/CSSStyleProperties.zig
new file mode 100644
index 000000000..199d12140
--- /dev/null
+++ b/src/browser/webapi/css/CSSStyleProperties.zig
@@ -0,0 +1,199 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+
+const Element = @import("../Element.zig");
+const Page = @import("../../Page.zig");
+const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig");
+
+const CSSStyleProperties = @This();
+
+_proto: *CSSStyleDeclaration,
+
+pub fn init(element: ?*Element, page: *Page) !*CSSStyleProperties {
+ return page._factory.create(CSSStyleProperties{
+ ._proto = try CSSStyleDeclaration.init(element, page),
+ });
+}
+
+pub fn asCSSStyleDeclaration(self: *CSSStyleProperties) *CSSStyleDeclaration {
+ return self._proto;
+}
+
+fn isKnownCSSProperty(dash_case: []const u8) bool {
+ // List of common/known CSS properties
+ // In a full implementation, this would include all standard CSS properties
+ const known_properties = std.StaticStringMap(void).initComptime(.{
+ .{ "color", {} },
+ .{ "background-color", {} },
+ .{ "font-size", {} },
+ .{ "margin-top", {} },
+ .{ "margin-bottom", {} },
+ .{ "margin-left", {} },
+ .{ "margin-right", {} },
+ .{ "padding-top", {} },
+ .{ "padding-bottom", {} },
+ .{ "padding-left", {} },
+ .{ "padding-right", {} },
+ .{ "border-top-left-radius", {} },
+ .{ "border-top-right-radius", {} },
+ .{ "border-bottom-left-radius", {} },
+ .{ "border-bottom-right-radius", {} },
+ .{ "float", {} },
+ .{ "z-index", {} },
+ .{ "width", {} },
+ .{ "height", {} },
+ .{ "display", {} },
+ .{ "position", {} },
+ .{ "top", {} },
+ .{ "bottom", {} },
+ .{ "left", {} },
+ .{ "right", {} },
+ });
+
+ return known_properties.has(dash_case);
+}
+
+fn camelCaseToDashCase(name: []const u8, buf: []u8) []const u8 {
+ if (name.len == 0) {
+ return name;
+ }
+
+ // Special case: cssFloat -> float
+ const lower_name = std.ascii.lowerString(buf, name);
+ if (std.mem.eql(u8, lower_name, "cssfloat")) {
+ return "float";
+ }
+
+ // If already contains dashes, just return lowercased
+ if (std.mem.indexOfScalar(u8, name, '-')) |_| {
+ return lower_name;
+ }
+
+ // Check if this looks like proper camelCase (starts with lowercase)
+ // If not (e.g. "COLOR", "BackgroundColor"), just lowercase it
+ if (name.len == 0 or !std.ascii.isLower(name[0])) {
+ return lower_name;
+ }
+
+ // Check for vendor prefixes: webkitTransform -> -webkit-transform
+ const has_vendor_prefix = name.len > 6 and (std.mem.startsWith(u8, name, "webkit") or
+ std.mem.startsWith(u8, name, "moz") or
+ std.mem.startsWith(u8, name, "ms") or
+ std.mem.startsWith(u8, name, "o"));
+
+ var write_pos: usize = 0;
+
+ if (has_vendor_prefix) {
+ buf[write_pos] = '-';
+ write_pos += 1;
+ }
+
+ for (name, 0..) |c, i| {
+ if (write_pos >= buf.len) {
+ return lower_name;
+ }
+
+ if (std.ascii.isUpper(c)) {
+ const skip_dash = has_vendor_prefix and i < 10 and write_pos == 1;
+
+ if (i > 0 and !skip_dash) {
+ if (write_pos >= buf.len) break;
+ buf[write_pos] = '-';
+ write_pos += 1;
+ }
+ if (write_pos >= buf.len) break;
+ buf[write_pos] = std.ascii.toLower(c);
+ write_pos += 1;
+ } else {
+ buf[write_pos] = c;
+ write_pos += 1;
+ }
+ }
+
+ return buf[0..write_pos];
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSStyleProperties);
+
+ pub const Meta = struct {
+ pub const name = "CSSStyleProperties";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const @"[]" = bridge.namedIndexed(_getPropertyIndexed, null, null, .{});
+
+ const method_names = std.StaticStringMap(void).initComptime(.{
+ .{ "getPropertyValue", {} },
+ .{ "setProperty", {} },
+ .{ "removeProperty", {} },
+ .{ "getPropertyPriority", {} },
+ .{ "item", {} },
+ .{ "cssText", {} },
+ .{ "length", {} },
+ });
+
+ fn _getPropertyIndexed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]const u8 {
+ if (method_names.has(name)) {
+ return error.NotHandled;
+ }
+
+ const dash_case = camelCaseToDashCase(name, &page.buf);
+
+ // Only apply vendor prefix filtering for camelCase access (no dashes in input)
+ // Bracket notation with dash-case (e.g., div.style['-moz-user-select']) should return the actual value
+ const is_camelcase_access = std.mem.indexOfScalar(u8, name, '-') == null;
+ if (is_camelcase_access and std.mem.startsWith(u8, dash_case, "-")) {
+ // We only support -webkit-, other vendor prefixes return undefined for camelCase access
+ const is_webkit = std.mem.startsWith(u8, dash_case, "-webkit-");
+ const is_moz = std.mem.startsWith(u8, dash_case, "-moz-");
+ const is_ms = std.mem.startsWith(u8, dash_case, "-ms-");
+ const is_o = std.mem.startsWith(u8, dash_case, "-o-");
+
+ if ((is_moz or is_ms or is_o) and !is_webkit) {
+ return error.NotHandled;
+ }
+ }
+
+ const value = self._proto.getPropertyValue(dash_case, page);
+
+ // Property accessors have special handling for empty values:
+ // - Known CSS properties return '' when not set
+ // - Vendor-prefixed properties return undefined when not set
+ // - Unknown properties return undefined
+ if (value.len == 0) {
+ // Vendor-prefixed properties always return undefined when not set
+ if (std.mem.startsWith(u8, dash_case, "-")) {
+ return error.NotHandled;
+ }
+
+ // Known CSS properties return '', unknown properties return undefined
+ if (!isKnownCSSProperty(dash_case)) {
+ return error.NotHandled;
+ }
+
+ return "";
+ }
+
+ return value;
+ }
+};
diff --git a/src/browser/webapi/css/CSSStyleRule.zig b/src/browser/webapi/css/CSSStyleRule.zig
new file mode 100644
index 000000000..c477621c7
--- /dev/null
+++ b/src/browser/webapi/css/CSSStyleRule.zig
@@ -0,0 +1,48 @@
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const CSSRule = @import("CSSRule.zig");
+const CSSStyleDeclaration = @import("CSSStyleDeclaration.zig");
+
+const CSSStyleRule = @This();
+
+_proto: *CSSRule,
+_selector_text: []const u8 = "",
+_style: ?*CSSStyleDeclaration = null,
+
+pub fn init(page: *Page) !*CSSStyleRule {
+ const rule = try CSSRule.init(.style, page);
+ return page._factory.create(CSSStyleRule{
+ ._proto = rule,
+ });
+}
+
+pub fn getSelectorText(self: *const CSSStyleRule) []const u8 {
+ return self._selector_text;
+}
+
+pub fn setSelectorText(self: *CSSStyleRule, text: []const u8, page: *Page) !void {
+ self._selector_text = try page.dupeString(text);
+}
+
+pub fn getStyle(self: *CSSStyleRule, page: *Page) !*CSSStyleDeclaration {
+ if (self._style) |style| {
+ return style;
+ }
+ const style = try CSSStyleDeclaration.init(null, page);
+ self._style = style;
+ return style;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSStyleRule);
+
+ pub const Meta = struct {
+ pub const name = "CSSStyleRule";
+ pub const prototype_chain = bridge.prototypeChain(CSSRule);
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const selectorText = bridge.accessor(CSSStyleRule.getSelectorText, CSSStyleRule.setSelectorText, .{});
+ pub const style = bridge.accessor(CSSStyleRule.getStyle, null, .{});
+};
diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig
new file mode 100644
index 000000000..2f8b76fb5
--- /dev/null
+++ b/src/browser/webapi/css/CSSStyleSheet.zig
@@ -0,0 +1,103 @@
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const CSSRuleList = @import("CSSRuleList.zig");
+const CSSRule = @import("CSSRule.zig");
+
+const CSSStyleSheet = @This();
+
+_href: ?[]const u8 = null,
+_title: []const u8 = "",
+_disabled: bool = false,
+_css_rules: ?*CSSRuleList = null,
+_owner_rule: ?*CSSRule = null,
+
+pub fn init(page: *Page) !*CSSStyleSheet {
+ return page._factory.create(CSSStyleSheet{});
+}
+
+pub fn getOwnerNode(self: *const CSSStyleSheet) ?*CSSStyleSheet {
+ _ = self;
+ return null;
+}
+
+pub fn getHref(self: *const CSSStyleSheet) ?[]const u8 {
+ return self._href;
+}
+
+pub fn getTitle(self: *const CSSStyleSheet) []const u8 {
+ return self._title;
+}
+
+pub fn getDisabled(self: *const CSSStyleSheet) bool {
+ return self._disabled;
+}
+
+pub fn setDisabled(self: *CSSStyleSheet, disabled: bool) void {
+ self._disabled = disabled;
+}
+
+pub fn getCssRules(self: *CSSStyleSheet, page: *Page) !*CSSRuleList {
+ if (self._css_rules) |rules| return rules;
+ const rules = try CSSRuleList.init(page);
+ self._css_rules = rules;
+ return rules;
+}
+
+pub fn getOwnerRule(self: *const CSSStyleSheet) ?*CSSRule {
+ return self._owner_rule;
+}
+
+pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, index: u32, page: *Page) !u32 {
+ _ = self;
+ _ = rule;
+ _ = index;
+ _ = page;
+ return 0;
+}
+
+pub fn deleteRule(self: *CSSStyleSheet, index: u32, page: *Page) !void {
+ _ = self;
+ _ = index;
+ _ = page;
+}
+
+pub fn replace(self: *CSSStyleSheet, text: []const u8, page: *Page) !js.Promise {
+ _ = self;
+ _ = text;
+ // TODO: clear self.css_rules
+ return page.js.resolvePromise({});
+}
+
+pub fn replaceSync(self: *CSSStyleSheet, text: []const u8) !void {
+ _ = self;
+ _ = text;
+ // TODO: clear self.css_rules
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(CSSStyleSheet);
+
+ pub const Meta = struct {
+ pub const name = "CSSStyleSheet";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(CSSStyleSheet.init, .{});
+ pub const ownerNode = bridge.accessor(CSSStyleSheet.getOwnerNode, null, .{ .null_as_undefined = true });
+ pub const href = bridge.accessor(CSSStyleSheet.getHref, null, .{ .null_as_undefined = true });
+ pub const title = bridge.accessor(CSSStyleSheet.getTitle, null, .{});
+ pub const disabled = bridge.accessor(CSSStyleSheet.getDisabled, CSSStyleSheet.setDisabled, .{});
+ pub const cssRules = bridge.accessor(CSSStyleSheet.getCssRules, null, .{});
+ pub const ownerRule = bridge.accessor(CSSStyleSheet.getOwnerRule, null, .{});
+ pub const insertRule = bridge.function(CSSStyleSheet.insertRule, .{});
+ pub const deleteRule = bridge.function(CSSStyleSheet.deleteRule, .{});
+ pub const replace = bridge.function(CSSStyleSheet.replace, .{});
+ pub const replaceSync = bridge.function(CSSStyleSheet.replaceSync, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: CSSStyleSheet" {
+ try testing.htmlRunner("css/stylesheet.html", .{});
+}
diff --git a/src/browser/webapi/css/MediaQueryList.zig b/src/browser/webapi/css/MediaQueryList.zig
new file mode 100644
index 000000000..46304ccc5
--- /dev/null
+++ b/src/browser/webapi/css/MediaQueryList.zig
@@ -0,0 +1,67 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+// zlint-disable unused-decls
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const EventTarget = @import("../EventTarget.zig");
+
+const MediaQueryList = @This();
+
+_proto: *EventTarget,
+_media: []const u8,
+
+pub fn deinit(self: *MediaQueryList) void {
+ _ = self;
+}
+
+pub fn asEventTarget(self: *MediaQueryList) *EventTarget {
+ return self._proto;
+}
+
+pub fn getMedia(self: *const MediaQueryList) []const u8 {
+ return self._media;
+}
+
+/// Always returns false for dummy implementation
+pub fn getMatches(_: *const MediaQueryList) bool {
+ return false;
+}
+
+pub fn addListener(_: *const MediaQueryList, _: js.Function) void {}
+pub fn removeListener(_: *const MediaQueryList, _: js.Function) void {}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(MediaQueryList);
+
+ pub const Meta = struct {
+ pub const name = "MediaQueryList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const media = bridge.accessor(MediaQueryList.getMedia, null, .{});
+ pub const matches = bridge.accessor(MediaQueryList.getMatches, null, .{});
+ pub const addListener = bridge.function(MediaQueryList.addListener, .{});
+ pub const removeListener = bridge.function(MediaQueryList.removeListener, .{});
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: MediaQueryList" {
+ try testing.htmlRunner("css/media_query_list.html", .{});
+}
diff --git a/src/browser/webapi/css/StyleSheetList.zig b/src/browser/webapi/css/StyleSheetList.zig
new file mode 100644
index 000000000..8a019a183
--- /dev/null
+++ b/src/browser/webapi/css/StyleSheetList.zig
@@ -0,0 +1,34 @@
+const std = @import("std");
+const js = @import("../../js/js.zig");
+const Page = @import("../../Page.zig");
+const CSSStyleSheet = @import("CSSStyleSheet.zig");
+
+const StyleSheetList = @This();
+
+_sheets: []*CSSStyleSheet = &.{},
+
+pub fn init(page: *Page) !*StyleSheetList {
+ return page._factory.create(StyleSheetList{});
+}
+
+pub fn length(self: *const StyleSheetList) u32 {
+ return @intCast(self._sheets.len);
+}
+
+pub fn item(self: *const StyleSheetList, index: usize) ?*CSSStyleSheet {
+ if (index >= self._sheets.len) return null;
+ return self._sheets[index];
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(StyleSheetList);
+
+ pub const Meta = struct {
+ pub const name = "StyleSheetList";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(StyleSheetList.length, null, .{});
+ pub const @"[]" = bridge.indexed(StyleSheetList.item, .{ .null_as_undefined = true });
+};
diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig
new file mode 100644
index 000000000..693b3842f
--- /dev/null
+++ b/src/browser/webapi/element/Attribute.zig
@@ -0,0 +1,621 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+const GenericIterator = @import("../collections/iterator.zig").Entry;
+
+const Page = @import("../../Page.zig");
+const String = @import("../../../string.zig").String;
+
+pub fn registerTypes() []const type {
+ return &.{
+ Attribute,
+ NamedNodeMap,
+ NamedNodeMap.Iterator,
+ };
+}
+
+pub const Attribute = @This();
+
+_proto: *Node,
+_name: []const u8,
+_value: []const u8,
+_element: ?*Element,
+
+pub fn format(self: *const Attribute, writer: *std.Io.Writer) !void {
+ return formatAttribute(self._name, self._value, writer);
+}
+
+pub fn className(_: *const Attribute) []const u8 {
+ return "Attr";
+}
+
+pub fn getName(self: *const Attribute) []const u8 {
+ return self._name;
+}
+
+pub fn getValue(self: *const Attribute) []const u8 {
+ return self._value;
+}
+
+pub fn setValue(self: *Attribute, data_: ?[]const u8, page: *Page) !void {
+ const data = data_ orelse "";
+ const el = self._element orelse {
+ self._value = try page.arena.dupe(u8, data);
+ return;
+ };
+ // this takes ownership of the data
+ try el.setAttribute(self._name, data, page);
+
+ // not the most efficient, but we don't expect this to be called oftens
+ self._value = (try el.getAttribute(self._name, page)) orelse "";
+}
+
+pub fn getNamespaceURI(_: *const Attribute) ?[]const u8 {
+ return null;
+}
+
+pub fn getOwnerElement(self: *const Attribute) ?*Element {
+ return self._element;
+}
+
+pub fn isEqualNode(self: *const Attribute, other: *const Attribute) bool {
+ return std.mem.eql(u8, self.getName(), other.getName()) and std.mem.eql(u8, self.getValue(), other.getValue());
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Attribute);
+
+ pub const Meta = struct {
+ pub const name = "Attr";
+ // we _never_ hold a reference to this, so the JS layer doesn't need to
+ // persist the value. It can pass it to QuickJS and let it fully manage it
+ // (TODO: we probably _should_ hold a refernece, because calling getAttributeNode
+ // on the same element + name should return the same instance)
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const name = bridge.accessor(Attribute.getName, null, .{});
+ pub const localName = bridge.accessor(Attribute.getName, null, .{});
+ pub const value = bridge.accessor(Attribute.getValue, null, .{});
+ pub const namespaceURI = bridge.accessor(Attribute.getNamespaceURI, null, .{});
+ pub const ownerElement = bridge.accessor(Attribute.getOwnerElement, null, .{});
+};
+
+// This is what an Element references. It isn't exposed to JavaScript. In
+// JavaScript, the element attribute list (el.attributes) is the NamedNodeMap
+// which exposes Attributes. It isn't ideal that we have both.
+// NamedNodeMap and Attribute are relatively fat and awkward to use. You can
+// imagine a page will have tens of thousands of attributes, and it's very likely
+// that page will _never_ load a single Attribute. It might get a string value
+// from a string key, but it won't load the full Attribute. And, even if it does,
+// it will almost certainly load realtively few.
+// The main issue with Attribute is that it's a full Node -> EventTarget. It's
+// _huge_ for something that's essentially just name=>value.
+// That said, we need identity. el.getAttributeNode("id") should return the same
+// Attribute value (the same JSValue) when called multiple time, and that gets
+// more important when you look at the [hardly every used] el.removeAttributeNode
+// and setAttributeNode.
+// So, we maintain a lookup, page._attribute_lookup, to serve as an identity map
+// from our internal Entry to a proper Attribute. This is lazily populated
+// whenever an Attribute is created. Why not just have an ?*Attribute field
+// in our Entry? Because that would require an extra 8 bytes for every single
+// attribute in the DOM, and, again, we expect that to almost always be null.
+pub const List = struct {
+ normalize: bool,
+ /// Length of items in `_list`. Not usize to increase memory usage.
+ /// Honestly, this is more than enough.
+ _len: u32 = 0,
+ _list: std.DoublyLinkedList = .{},
+
+ pub fn isEmpty(self: *const List) bool {
+ return self._list.first == null;
+ }
+
+ pub fn get(self: *const List, name: []const u8, page: *Page) !?[]const u8 {
+ const entry = (try self.getEntry(name, page)) orelse return null;
+ return entry._value.str();
+ }
+
+ pub inline fn length(self: *const List) usize {
+ return self._len;
+ }
+
+ /// Compares 2 attribute lists for equality.
+ pub fn eql(self: *List, other: *List) bool {
+ if (self.length() != other.length()) {
+ return false;
+ }
+
+ var iter = self.iterator();
+ search: while (iter.next()) |attr| {
+ // Iterate over all `other` attributes.
+ var other_iter = other.iterator();
+ while (other_iter.next()) |other_attr| {
+ if (attr.eql(other_attr)) {
+ continue :search; // Found match.
+ }
+ }
+ // Iterated over all `other` and not match.
+ return false;
+ }
+ return true;
+ }
+
+ // meant for internal usage, where the name is known to be properly cased
+ pub fn getSafe(self: *const List, name: []const u8) ?[]const u8 {
+ const entry = self.getEntryWithNormalizedName(name) orelse return null;
+ return entry._value.str();
+ }
+
+ // meant for internal usage, where the name is known to be properly cased
+ pub fn hasSafe(self: *const List, name: []const u8) bool {
+ return self.getEntryWithNormalizedName(name) != null;
+ }
+
+ pub fn getAttribute(self: *const List, name: []const u8, element: ?*Element, page: *Page) !?*Attribute {
+ const entry = (try self.getEntry(name, page)) orelse return null;
+ const gop = try page._attribute_lookup.getOrPut(page.arena, @intFromPtr(entry));
+ if (gop.found_existing) {
+ return gop.value_ptr.*;
+ }
+ const attribute = try entry.toAttribute(element, page);
+ gop.value_ptr.* = attribute;
+ return attribute;
+ }
+
+ pub fn put(self: *List, name: []const u8, value: []const u8, element: *Element, page: *Page) !*Entry {
+ const result = try self.getEntryAndNormalizedName(name, page);
+ return self._put(result, value, element, page);
+ }
+
+ pub fn putSafe(self: *List, name: []const u8, value: []const u8, element: *Element, page: *Page) !*Entry {
+ const entry = self.getEntryWithNormalizedName(name);
+ return self._put(.{ .entry = entry, .normalized = name }, value, element, page);
+ }
+
+ fn _put(self: *List, result: NormalizeAndEntry, value: []const u8, element: *Element, page: *Page) !*Entry {
+ const is_id = shouldAddToIdMap(result.normalized, element);
+
+ var entry: *Entry = undefined;
+ var old_value: ?[]const u8 = null;
+ if (result.entry) |e| {
+ old_value = try page.call_arena.dupe(u8, e._value.str());
+ if (is_id) {
+ page.removeElementId(element, e._value.str());
+ }
+ e._value = try String.init(page.arena, value, .{});
+ entry = e;
+ } else {
+ entry = try page._factory.create(Entry{
+ ._node = .{},
+ ._name = try String.init(page.arena, result.normalized, .{}),
+ ._value = try String.init(page.arena, value, .{}),
+ });
+ self._list.append(&entry._node);
+ self._len += 1;
+ }
+
+ if (is_id) {
+ const parent = element.asNode()._parent orelse {
+ std.debug.assert(false);
+ return entry;
+ };
+ try page.addElementId(parent, element, entry._value.str());
+ }
+ page.domChanged();
+ page.attributeChange(element, result.normalized, entry._value.str(), old_value);
+ return entry;
+ }
+
+ // Optimized for cloning. We know `name` is already normalized. We know there isn't duplicates.
+ // We know the Element is detatched (and thus, don't need to check for `id`).
+ pub fn putForCloned(self: *List, name: []const u8, value: []const u8, page: *Page) !void {
+ const entry = try page._factory.create(Entry{
+ ._node = .{},
+ ._name = try String.init(page.arena, name, .{}),
+ ._value = try String.init(page.arena, value, .{}),
+ });
+ self._list.append(&entry._node);
+ self._len += 1;
+ }
+
+ // not efficient, won't be called often (if ever!)
+ pub fn putAttribute(self: *List, attribute: *Attribute, element: *Element, page: *Page) !?*Attribute {
+ // we expect our caller to make sure this is true
+ std.debug.assert(attribute._element == null);
+
+ const existing_attribute = try self.getAttribute(attribute._name, element, page);
+ if (existing_attribute) |ea| {
+ try self.delete(ea._name, element, page);
+ }
+
+ const entry = try self.put(attribute._name, attribute._value, element, page);
+ attribute._element = element;
+ try page._attribute_lookup.put(page.arena, @intFromPtr(entry), attribute);
+ return existing_attribute;
+ }
+
+ // called form our parser, names already lower-cased
+ pub fn putNew(self: *List, name: []const u8, value: []const u8, page: *Page) !void {
+ if (try self.getEntry(name, page) != null) {
+ // When parsing, if there are dupicate names, it isn't valid, and
+ // the first is kept
+ return;
+ }
+
+ const entry = try page._factory.create(Entry{
+ ._node = .{},
+ ._name = try String.init(page.arena, name, .{}),
+ ._value = try String.init(page.arena, value, .{}),
+ });
+ self._list.append(&entry._node);
+ self._len += 1;
+ }
+
+ pub fn delete(self: *List, name: []const u8, element: *Element, page: *Page) !void {
+ const result = try self.getEntryAndNormalizedName(name, page);
+ const entry = result.entry orelse return;
+
+ const is_id = shouldAddToIdMap(result.normalized, element);
+ const old_value = entry._value.str();
+
+ if (is_id) {
+ page.removeElementId(element, entry._value.str());
+ }
+
+ page.domChanged();
+ page.attributeRemove(element, result.normalized, old_value);
+ _ = page._attribute_lookup.remove(@intFromPtr(entry));
+ self._list.remove(&entry._node);
+ self._len -= 1;
+ page._factory.destroy(entry);
+ }
+
+ pub fn getNames(self: *const List, page: *Page) ![][]const u8 {
+ var arr: std.ArrayList([]const u8) = .empty;
+ var node = self._list.first;
+ while (node) |n| {
+ try arr.append(page.call_arena, Entry.fromNode(n)._name.str());
+ node = n.next;
+ }
+ return arr.items;
+ }
+
+ pub fn iterator(self: *List) InnerIterator {
+ return .{ ._node = self._list.first };
+ }
+
+ fn getEntry(self: *const List, name: []const u8, page: *Page) !?*Entry {
+ const result = try self.getEntryAndNormalizedName(name, page);
+ return result.entry;
+ }
+
+ // Dangerous, the returned normalized name is only valid until someone
+ // else uses pages.buf.
+ const NormalizeAndEntry = struct {
+ normalized: []const u8,
+ entry: ?*Entry,
+ };
+ fn getEntryAndNormalizedName(self: *const List, name: []const u8, page: *Page) !NormalizeAndEntry {
+ const normalized =
+ if (self.normalize) try normalizeNameForLookup(name, page) else name;
+
+ return .{
+ .normalized = normalized,
+ .entry = self.getEntryWithNormalizedName(normalized),
+ };
+ }
+
+ fn getEntryWithNormalizedName(self: *const List, name: []const u8) ?*Entry {
+ var node = self._list.first;
+ while (node) |n| {
+ var e = Entry.fromNode(n);
+ if (e._name.eqlSlice(name)) {
+ return e;
+ }
+ node = n.next;
+ }
+ return null;
+ }
+
+ pub const Entry = struct {
+ _name: String,
+ _value: String,
+ _node: std.DoublyLinkedList.Node,
+
+ fn fromNode(n: *std.DoublyLinkedList.Node) *Entry {
+ return @alignCast(@fieldParentPtr("_node", n));
+ }
+
+ /// Returns true if 2 entries are equal.
+ /// This doesn't compare `_node` fields.
+ pub fn eql(self: *const Entry, other: *const Entry) bool {
+ return self._name.eql(other._name) and self._value.eql(other._value);
+ }
+
+ pub fn format(self: *const Entry, writer: *std.Io.Writer) !void {
+ return formatAttribute(self._name.str(), self._value.str(), writer);
+ }
+
+ pub fn toAttribute(self: *const Entry, element: ?*Element, page: *Page) !*Attribute {
+ return page._factory.node(Attribute{
+ ._proto = undefined,
+ ._element = element,
+ // Cannot directly reference self._name.str() and self._value.str()
+ // This attribute can outlive the list entry (the node can be
+ // removed from the element's attribute, but still exist in the DOM)
+ ._name = try page.dupeString(self._name.str()),
+ ._value = try page.dupeString(self._value.str()),
+ });
+ }
+ };
+};
+
+fn shouldAddToIdMap(normalized_name: []const u8, element: *Element) bool {
+ if (!std.mem.eql(u8, normalized_name, "id")) {
+ return false;
+ }
+
+ const node = element.asNode();
+ // Shadow tree elements are always added to their shadow root's map
+ if (node.isInShadowTree()) {
+ return true;
+ }
+ // Document tree elements only when connected
+ return node.isConnected();
+}
+
+pub fn validateAttributeName(name: []const u8) !void {
+ if (name.len == 0) {
+ return error.InvalidCharacterError;
+ }
+
+ const first = name[0];
+ if ((first >= '0' and first <= '9') or first == '-' or first == '.') {
+ return error.InvalidCharacterError;
+ }
+
+ for (name) |c| {
+ if (c == 0 or c == '/' or c == '=' or c == '>' or std.ascii.isWhitespace(c)) {
+ return error.InvalidCharacterError;
+ }
+
+ const is_valid = (c >= 'a' and c <= 'z') or
+ (c >= 'A' and c <= 'Z') or
+ (c >= '0' and c <= '9') or
+ c == '_' or c == '-' or c == '.' or c == ':';
+
+ if (!is_valid) {
+ return error.InvalidCharacterError;
+ }
+ }
+}
+
+pub fn normalizeNameForLookup(name: []const u8, page: *Page) ![]const u8 {
+ if (!needsLowerCasing(name)) {
+ return name;
+ }
+ if (name.len < page.buf.len) {
+ return std.ascii.lowerString(&page.buf, name);
+ }
+ return std.ascii.allocLowerString(page.call_arena, name);
+}
+
+fn needsLowerCasing(name: []const u8) bool {
+ var remaining = name;
+ if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
+ while (remaining.len > vector_len) {
+ const chunk: @Vector(vector_len, u8) = remaining[0..vector_len].*;
+ if (@reduce(.Min, chunk) <= 'Z') {
+ return true;
+ }
+ remaining = remaining[vector_len..];
+ }
+ }
+
+ for (remaining) |b| {
+ if (std.ascii.isUpper(b)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+pub const NamedNodeMap = struct {
+ _list: *List,
+
+ // Whenever the NamedNodeMap creates an Attribute, it needs to provide the
+ // "ownerElement".
+ _element: ?*Element = null,
+
+ pub fn length(self: *const NamedNodeMap) u32 {
+ return @intCast(self._list._list.len());
+ }
+
+ pub fn getAtIndex(self: *const NamedNodeMap, index: usize, page: *Page) !?*Attribute {
+ var i: usize = 0;
+ var node = self._list._list.first;
+ while (node) |n| {
+ if (i == index) {
+ var entry = List.Entry.fromNode(n);
+ const gop = try page._attribute_lookup.getOrPut(page.arena, @intFromPtr(entry));
+ if (gop.found_existing) {
+ return gop.value_ptr.*;
+ }
+ const attribute = try entry.toAttribute(self._element, page);
+ gop.value_ptr.* = attribute;
+ return attribute;
+ }
+ node = n.next;
+ i += 1;
+ }
+ return null;
+ }
+
+ pub fn getByName(self: *const NamedNodeMap, name: []const u8, page: *Page) !?*Attribute {
+ return self._list.getAttribute(name, self._element, page);
+ }
+
+ pub fn iterator(self: *const NamedNodeMap, page: *Page) !*Iterator {
+ return .init(.{ .list = self }, page);
+ }
+
+ pub const Iterator = GenericIterator(struct {
+ index: usize = 0,
+ list: *const NamedNodeMap,
+
+ pub fn next(self: *@This(), page: *Page) !?*Attribute {
+ const index = self.index;
+ self.index = index + 1;
+ return self.list.getAtIndex(index, page);
+ }
+ }, null);
+
+ pub const JsApi = struct {
+ pub const bridge = js.Bridge(NamedNodeMap);
+
+ pub const Meta = struct {
+ pub const name = "NamedNodeMap";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const length = bridge.accessor(NamedNodeMap.length, null, .{});
+ 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 item = bridge.function(_item, .{});
+ fn _item(self: *const NamedNodeMap, index: i32, page: *Page) !?*Attribute {
+ // the bridge.indexed handles this, so if we want
+ // list.item(-2) to return the same as list[-2] we need to
+ // 1 - take an i32 for the index
+ // 2 - return null if it's < 0
+ if (index < 0) {
+ return null;
+ }
+ return self.getAtIndex(@intCast(index), page);
+ }
+ pub const symbol_iterator = bridge.iterator(NamedNodeMap.iterator, .{});
+ };
+};
+
+// Not meant to be exposed. The "public" iterator is a NamedNodeMap, and it's a
+// bit awkward. Having this for more straightforward key=>value is useful for
+// the few internal places we need to iterate through the attributes (e.g. dump)
+pub const InnerIterator = struct {
+ _node: ?*std.DoublyLinkedList.Node = null,
+
+ pub fn next(self: *InnerIterator) ?*List.Entry {
+ const node = self._node orelse return null;
+ self._node = node.next;
+ return List.Entry.fromNode(node);
+ }
+};
+
+fn formatAttribute(name: []const u8, value: []const u8, writer: *std.Io.Writer) !void {
+ try writer.writeAll(name);
+
+ // Boolean attributes with empty values are serialized without a value
+ if (value.len == 0 and boolean_attributes_lookup.has(name)) {
+ return;
+ }
+
+ try writer.writeByte('=');
+ if (value.len == 0) {
+ return writer.writeAll("\"\"");
+ }
+
+ try writer.writeByte('"');
+ const offset = std.mem.indexOfAny(u8, value, "`' &\"<>=") orelse {
+ try writer.writeAll(value);
+ return writer.writeByte('"');
+ };
+
+ try writeEscapedAttributeValue(value, offset, writer);
+ return writer.writeByte('"');
+}
+
+const boolean_attributes = [_][]const u8{
+ "checked",
+ "disabled",
+ "required",
+ "readonly",
+ "multiple",
+ "selected",
+ "autofocus",
+ "autoplay",
+ "controls",
+ "loop",
+ "muted",
+ "hidden",
+ "async",
+ "defer",
+ "novalidate",
+ "formnovalidate",
+ "ismap",
+ "reversed",
+ "default",
+ "open",
+};
+
+const boolean_attributes_lookup = std.StaticStringMap(void).initComptime(blk: {
+ var entries: [boolean_attributes.len]struct { []const u8, void } = undefined;
+ for (boolean_attributes, 0..) |attr, i| {
+ entries[i] = .{ attr, {} };
+ }
+ break :blk entries;
+});
+
+fn writeEscapedAttributeValue(value: []const u8, first_offset: usize, writer: *std.Io.Writer) !void {
+ // Write everything before the first special character
+ try writer.writeAll(value[0..first_offset]);
+ try writer.writeAll(switch (value[first_offset]) {
+ '&' => "&",
+ '"' => """,
+ '<' => "<",
+ '>' => ">",
+ '=' => "=",
+ ' ' => " ",
+ '`' => "`",
+ '\'' => "'",
+ else => unreachable,
+ });
+
+ var remaining = value[first_offset + 1 ..];
+ while (std.mem.indexOfAny(u8, remaining, "&\"<>")) |offset| {
+ try writer.writeAll(remaining[0..offset]);
+ try writer.writeAll(switch (remaining[offset]) {
+ '&' => "&",
+ '"' => """,
+ '<' => "<",
+ '>' => ">",
+ else => unreachable,
+ });
+ remaining = remaining[offset + 1 ..];
+ }
+
+ if (remaining.len > 0) {
+ try writer.writeAll(remaining);
+ }
+}
diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig
new file mode 100644
index 000000000..518f996a8
--- /dev/null
+++ b/src/browser/webapi/element/DOMStringMap.zig
@@ -0,0 +1,105 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../js/js.zig");
+
+const Element = @import("../Element.zig");
+const Page = @import("../../Page.zig");
+
+const Allocator = std.mem.Allocator;
+
+const DOMStringMap = @This();
+
+_element: *Element,
+
+fn _getProperty(self: *DOMStringMap, name: []const u8, page: *Page) !?[]const u8 {
+ const attr_name = try camelToKebab(page.call_arena, name);
+ return try self._element.getAttribute(attr_name, page);
+}
+
+fn _setProperty(self: *DOMStringMap, name: []const u8, value: []const u8, page: *Page) !void {
+ const attr_name = try camelToKebab(page.call_arena, name);
+ return self._element.setAttributeSafe(attr_name, value, page);
+}
+
+fn _deleteProperty(self: *DOMStringMap, name: []const u8, page: *Page) !void {
+ const attr_name = try camelToKebab(page.call_arena, name);
+ try self._element.removeAttribute(attr_name, page);
+}
+
+// fooBar -> foo-bar
+fn camelToKebab(arena: Allocator, camel: []const u8) ![]const u8 {
+ var result: std.ArrayList(u8) = .empty;
+ try result.ensureTotalCapacity(arena, 5 + camel.len * 2);
+ result.appendSliceAssumeCapacity("data-");
+
+ for (camel, 0..) |c, i| {
+ if (std.ascii.isUpper(c)) {
+ if (i > 0) {
+ result.appendAssumeCapacity('-');
+ }
+ result.appendAssumeCapacity(std.ascii.toLower(c));
+ } else {
+ result.appendAssumeCapacity(c);
+ }
+ }
+
+ return result.items;
+}
+
+// data-foo-bar -> fooBar
+fn kebabToCamel(arena: Allocator, kebab: []const u8) !?[]const u8 {
+ if (!std.mem.startsWith(u8, kebab, "data-")) {
+ return null;
+ }
+
+ const data_part = kebab[5..]; // Skip "data-"
+ if (data_part.len == 0) {
+ return null;
+ }
+
+ var result: std.ArrayList(u8) = .empty;
+ try result.ensureTotalCapacity(arena, data_part.len);
+
+ var capitalize_next = false;
+ for (data_part) |c| {
+ if (c == '-') {
+ capitalize_next = true;
+ } else if (capitalize_next) {
+ result.appendAssumeCapacity(std.ascii.toUpper(c));
+ capitalize_next = false;
+ } else {
+ result.appendAssumeCapacity(c);
+ }
+ }
+
+ return result.items;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(DOMStringMap);
+
+ pub const Meta = struct {
+ pub const name = "DOMStringMap";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const @"[]" = bridge.namedIndexed(_getProperty, _setProperty, _deleteProperty, .{ .null_as_undefined = true });
+};
diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig
new file mode 100644
index 000000000..8016c8be6
--- /dev/null
+++ b/src/browser/webapi/element/Html.zig
@@ -0,0 +1,205 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../../js/js.zig");
+const reflect = @import("../../reflect.zig");
+
+const Page = @import("../../Page.zig");
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+
+pub const Anchor = @import("html/Anchor.zig");
+pub const Body = @import("html/Body.zig");
+pub const BR = @import("html/BR.zig");
+pub const Button = @import("html/Button.zig");
+pub const Custom = @import("html/Custom.zig");
+pub const Data = @import("html/Data.zig");
+pub const Dialog = @import("html/Dialog.zig");
+pub const Div = @import("html/Div.zig");
+pub const Embed = @import("html/Embed.zig");
+pub const Form = @import("html/Form.zig");
+pub const Generic = @import("html/Generic.zig");
+pub const Head = @import("html/Head.zig");
+pub const Heading = @import("html/Heading.zig");
+pub const HR = @import("html/HR.zig");
+pub const Html = @import("html/Html.zig");
+pub const IFrame = @import("html/IFrame.zig");
+pub const Image = @import("html/Image.zig");
+pub const Input = @import("html/Input.zig");
+pub const LI = @import("html/LI.zig");
+pub const Link = @import("html/Link.zig");
+pub const Media = @import("html/Media.zig");
+pub const Meta = @import("html/Meta.zig");
+pub const OL = @import("html/OL.zig");
+pub const Option = @import("html/Option.zig");
+pub const Paragraph = @import("html/Paragraph.zig");
+pub const Script = @import("html/Script.zig");
+pub const Select = @import("html/Select.zig");
+pub const Slot = @import("html/Slot.zig");
+pub const Style = @import("html/Style.zig");
+pub const Template = @import("html/Template.zig");
+pub const TextArea = @import("html/TextArea.zig");
+pub const Title = @import("html/Title.zig");
+pub const UL = @import("html/UL.zig");
+pub const Unknown = @import("html/Unknown.zig");
+
+const HtmlElement = @This();
+
+_type: Type,
+_proto: *Element,
+
+// Special constructor for custom elements
+pub fn construct(page: *Page) !*Element {
+ const node = page._upgrading_element orelse return error.IllegalConstructor;
+ return node.is(Element) orelse return error.IllegalConstructor;
+}
+
+pub const Type = union(enum) {
+ anchor: *Anchor,
+ body: *Body,
+ br: *BR,
+ button: *Button,
+ custom: *Custom,
+ data: *Data,
+ dialog: *Dialog,
+ div: *Div,
+ embed: *Embed,
+ form: *Form,
+ generic: *Generic,
+ heading: *Heading,
+ head: *Head,
+ html: *Html,
+ hr: *HR,
+ img: *Image,
+ iframe: *IFrame,
+ input: *Input,
+ li: *LI,
+ link: *Link,
+ media: *Media,
+ meta: *Meta,
+ ol: *OL,
+ option: *Option,
+ p: *Paragraph,
+ script: *Script,
+ select: *Select,
+ slot: *Slot,
+ style: *Style,
+ template: *Template,
+ text_area: *TextArea,
+ title: *Title,
+ ul: *UL,
+ unknown: *Unknown,
+};
+
+pub fn is(self: *HtmlElement, comptime T: type) ?*T {
+ inline for (@typeInfo(Type).@"union".fields) |f| {
+ if (@field(Type, f.name) == self._type) {
+ if (f.type == T) {
+ return &@field(self._type, f.name);
+ }
+ if (f.type == *T) {
+ return @field(self._type, f.name);
+ }
+ }
+ }
+ return null;
+}
+
+pub fn className(self: *const HtmlElement) []const u8 {
+ return switch (self._type) {
+ .anchor => "[object HTMLAnchorElement]",
+ .body => "[object HTMLBodyElement]",
+ .br => "[object HTMLBRElement]",
+ .button => "[object HTMLButtonElement]",
+ .custom => "[object CUSTOM-TODO]",
+ .data => "[object HTMLDataElement]",
+ .dialog => "[object HTMLDialogElement]",
+ .div => "[object HTMLDivElement]",
+ .embed => "[object HTMLEmbedElement]",
+ .form => "[object HTMLFormElement]",
+ .generic => "[object HTMLElement]",
+ .head => "[object HTMLHeadElement]",
+ .heading => "[object HTMLHeadingElement]",
+ .hr => "[object HTMLHRElement]",
+ .html => "[object HTMLHtmlElement]",
+ .iframe => "[object HTMLIFrameElement]",
+ .img => "[object HTMLImageElement]",
+ .input => "[object HTMLInputElement]",
+ .li => "[object HTMLLIElement]",
+ .link => "[object HTMLLinkElement]",
+ .meta => "[object HTMLMetaElement]",
+ .media => |m| switch (m._type) {
+ .audio => "[object HTMLAudioElement]",
+ .video => "[object HTMLVideoElement]",
+ .generic => "[object HTMLMediaElement]",
+ },
+ .ol => "[object HTMLOLElement]",
+ .option => "[object HTMLOptionElement]",
+ .p => "[object HTMLParagraphElement]",
+ .script => "[object HTMLScriptElement]",
+ .select => "[object HTMLSelectElement]",
+ .slot => "[object HTMLSlotElement]",
+ .style => "[object HTMLSyleElement]",
+ .template => "[object HTMLTemplateElement]",
+ .text_area => "[object HTMLTextAreaElement]",
+ .title => "[object HTMLTitleElement]",
+ .ul => "[object HTMLULElement]",
+ .unknown => "[object HTMLUnknownElement]",
+ };
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(HtmlElement);
+
+ pub const Meta = struct {
+ pub const name = "HTMLElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const constructor = bridge.constructor(HtmlElement.construct, .{});
+};
+
+pub const Build = struct {
+ // Calls `func_name` with `args` on the most specific type where it is
+ // implement. This could be on the HtmlElement itself.
+ pub fn call(self: *const HtmlElement, comptime func_name: []const u8, args: anytype) !bool {
+ inline for (@typeInfo(HtmlElement.Type).@"union".fields) |f| {
+ if (@field(HtmlElement.Type, f.name) == self._type) {
+ // The inner type implements this function. Call it and we're done.
+ const S = reflect.Struct(f.type);
+ if (@hasDecl(S, "Build")) {
+ if (@hasDecl(S.Build, func_name)) {
+ try @call(.auto, @field(S.Build, func_name), args);
+ return true;
+ }
+ }
+ }
+ }
+
+ if (@hasDecl(HtmlElement.Build, func_name)) {
+ // Our last resort - the node implements this function.
+ try @call(.auto, @field(HtmlElement.Build, func_name), args);
+ return true;
+ }
+
+ // inform our caller (the Element) that we didn't find anything that implemented
+ // func_name and it should keep searching for a match.
+ return false;
+ }
+};
diff --git a/src/browser/webapi/element/Svg.zig b/src/browser/webapi/element/Svg.zig
new file mode 100644
index 000000000..71a7cab9c
--- /dev/null
+++ b/src/browser/webapi/element/Svg.zig
@@ -0,0 +1,79 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const String = @import("../../../string.zig").String;
+
+const js = @import("../../js/js.zig");
+
+const Node = @import("../Node.zig");
+const Element = @import("../Element.zig");
+
+pub const Generic = @import("svg/Generic.zig");
+
+const Svg = @This();
+_type: Type,
+_proto: *Element,
+_tag_name: String, // Svg elements are case-preserving
+
+pub const Type = union(enum) {
+ svg,
+ generic: *Generic,
+};
+
+pub fn is(self: *Svg, comptime T: type) ?*T {
+ inline for (@typeInfo(Type).@"union".fields) |f| {
+ if (@field(Type, f.name) == self._type) {
+ if (f.type == T) {
+ return &@field(self._type, f.name);
+ }
+ if (f.type == *T) {
+ return @field(self._type, f.name);
+ }
+ }
+ }
+ return null;
+}
+
+pub fn asElement(self: *Svg) *Element {
+ return self._proto;
+}
+pub fn asNode(self: *Svg) *Node {
+ return self.asElement().asNode();
+}
+
+pub fn className(self: *const Svg) []const u8 {
+ return switch (self._type) {
+ .svg => "SVGElement",
+ inline else => |svg| svg.className(),
+ };
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Svg);
+
+ pub const Meta = struct {
+ pub const name = "SVGElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: Svg" {
+ try testing.htmlRunner("element/svg", .{});
+}
diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig
new file mode 100644
index 000000000..75e61c205
--- /dev/null
+++ b/src/browser/webapi/element/html/Anchor.zig
@@ -0,0 +1,229 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const std = @import("std");
+const js = @import("../../../js/js.zig");
+const Page = @import("../../../Page.zig");
+
+const URL = @import("../../../URL.zig");
+const Node = @import("../../Node.zig");
+const Element = @import("../../Element.zig");
+const HtmlElement = @import("../Html.zig");
+
+const Anchor = @This();
+_proto: *HtmlElement,
+
+pub fn asElement(self: *Anchor) *Element {
+ return self._proto._proto;
+}
+pub fn asConstElement(self: *const Anchor) *const Element {
+ return self._proto._proto;
+}
+pub fn asNode(self: *Anchor) *Node {
+ return self.asElement().asNode();
+}
+
+pub fn getHref(self: *Anchor, page: *Page) ![]const u8 {
+ const element = self.asElement();
+ const href = element.getAttributeSafe("href") orelse return "";
+ if (href.len == 0) {
+ return "";
+ }
+ return URL.resolve(page.call_arena, page.url, href, .{});
+}
+
+pub fn setHref(self: *Anchor, value: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("href", value, page);
+}
+
+pub fn getTarget(self: *Anchor) []const u8 {
+ return self.asElement().getAttributeSafe("target") orelse "";
+}
+
+pub fn setTarget(self: *Anchor, value: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("target", value, page);
+}
+
+pub fn getOrigin(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return (try URL.getOrigin(page.call_arena, href)) orelse "null";
+}
+
+pub fn getHost(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ const host = URL.getHost(href);
+ const protocol = URL.getProtocol(href);
+ const port = URL.getPort(href);
+
+ // Strip default ports
+ if (port.len > 0) {
+ if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or
+ (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80")))
+ {
+ return URL.getHostname(href);
+ }
+ }
+
+ return host;
+}
+
+pub fn setHost(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setHost(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getHostname(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return URL.getHostname(href);
+}
+
+pub fn setHostname(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setHostname(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getPort(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ const port = URL.getPort(href);
+ const protocol = URL.getProtocol(href);
+
+ // Return empty string for default ports
+ if (port.len > 0) {
+ if ((std.mem.eql(u8, protocol, "https:") and std.mem.eql(u8, port, "443")) or
+ (std.mem.eql(u8, protocol, "http:") and std.mem.eql(u8, port, "80")))
+ {
+ return "";
+ }
+ }
+
+ return port;
+}
+
+pub fn setPort(self: *Anchor, value: ?[]const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setPort(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getSearch(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return URL.getSearch(href);
+}
+
+pub fn setSearch(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setSearch(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getHash(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return URL.getHash(href);
+}
+
+pub fn setHash(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setHash(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getPathname(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return URL.getPathname(href);
+}
+
+pub fn setPathname(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setPathname(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getProtocol(self: *Anchor, page: *Page) ![]const u8 {
+ const href = try getResolvedHref(self, page) orelse return "";
+ return URL.getProtocol(href);
+}
+
+pub fn setProtocol(self: *Anchor, value: []const u8, page: *Page) !void {
+ const href = try getResolvedHref(self, page) orelse return;
+ const new_href = try URL.setProtocol(href, value, page.call_arena);
+ try setHref(self, new_href, page);
+}
+
+pub fn getType(self: *Anchor) []const u8 {
+ return self.asElement().getAttributeSafe("type") orelse "";
+}
+
+pub fn setType(self: *Anchor, value: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("type", value, page);
+}
+
+pub fn getName(self: *const Anchor) []const u8 {
+ return self.asConstElement().getAttributeSafe("name") orelse "";
+}
+
+pub fn setName(self: *Anchor, value: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("name", value, page);
+}
+
+pub fn getText(self: *Anchor, page: *Page) ![:0]const u8 {
+ return self.asNode().getTextContentAlloc(page.call_arena);
+}
+
+pub fn setText(self: *Anchor, value: []const u8, page: *Page) !void {
+ try self.asNode().setTextContent(value, page);
+}
+
+fn getResolvedHref(self: *Anchor, page: *Page) !?[:0]const u8 {
+ const href = self.asElement().getAttributeSafe("href") orelse return null;
+ if (href.len == 0) {
+ return null;
+ }
+ return try URL.resolve(page.call_arena, page.url, href, .{});
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Anchor);
+
+ pub const Meta = struct {
+ pub const name = "HTMLAnchorElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const href = bridge.accessor(Anchor.getHref, Anchor.setHref, .{});
+ pub const target = bridge.accessor(Anchor.getTarget, Anchor.setTarget, .{});
+ pub const name = bridge.accessor(Anchor.getName, Anchor.setName, .{});
+ pub const origin = bridge.accessor(Anchor.getOrigin, null, .{});
+ pub const protocol = bridge.accessor(Anchor.getProtocol, Anchor.setProtocol, .{});
+ pub const host = bridge.accessor(Anchor.getHost, Anchor.setHost, .{});
+ pub const hostname = bridge.accessor(Anchor.getHostname, Anchor.setHostname, .{});
+ pub const port = bridge.accessor(Anchor.getPort, Anchor.setPort, .{});
+ pub const pathname = bridge.accessor(Anchor.getPathname, Anchor.setPathname, .{});
+ pub const search = bridge.accessor(Anchor.getSearch, Anchor.setSearch, .{});
+ 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 toString = bridge.function(Anchor.getHref, .{});
+};
+
+const testing = @import("../../../../testing.zig");
+test "WebApi: HTML.Anchor" {
+ try testing.htmlRunner("element/html/anchor.html", .{});
+}
diff --git a/src/browser/webapi/element/html/Audio.zig b/src/browser/webapi/element/html/Audio.zig
new file mode 100644
index 000000000..11572f99c
--- /dev/null
+++ b/src/browser/webapi/element/html/Audio.zig
@@ -0,0 +1,49 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../../../js/js.zig");
+
+const Node = @import("../../Node.zig");
+const Element = @import("../../Element.zig");
+const Media = @import("Media.zig");
+
+const Audio = @This();
+
+_proto: *Media,
+
+pub fn asMedia(self: *Audio) *Media {
+ return self._proto;
+}
+
+pub fn asElement(self: *Audio) *Element {
+ return self._proto.asElement();
+}
+
+pub fn asNode(self: *Audio) *Node {
+ return self.asElement().asNode();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Audio);
+
+ pub const Meta = struct {
+ pub const name = "HTMLAudioElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+};
diff --git a/src/browser/webapi/element/html/BR.zig b/src/browser/webapi/element/html/BR.zig
new file mode 100644
index 000000000..e6dcbd4aa
--- /dev/null
+++ b/src/browser/webapi/element/html/BR.zig
@@ -0,0 +1,25 @@
+const js = @import("../../../js/js.zig");
+const Node = @import("../../Node.zig");
+const Element = @import("../../Element.zig");
+const HtmlElement = @import("../Html.zig");
+
+const BR = @This();
+
+_proto: *HtmlElement,
+
+pub fn asElement(self: *BR) *Element {
+ return self._proto._proto;
+}
+pub fn asNode(self: *BR) *Node {
+ return self.asElement().asNode();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(BR);
+
+ pub const Meta = struct {
+ pub const name = "HTMLBRElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+};
diff --git a/src/browser/webapi/element/html/Body.zig b/src/browser/webapi/element/html/Body.zig
new file mode 100644
index 000000000..5be6d4fef
--- /dev/null
+++ b/src/browser/webapi/element/html/Body.zig
@@ -0,0 +1,58 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const log = @import("../../../../log.zig");
+
+const js = @import("../../../js/js.zig");
+const Page = @import("../../../Page.zig");
+
+const Node = @import("../../Node.zig");
+const Element = @import("../../Element.zig");
+const HtmlElement = @import("../Html.zig");
+
+const Body = @This();
+
+_proto: *HtmlElement,
+
+pub fn asElement(self: *Body) *Element {
+ return self._proto._proto;
+}
+pub fn asNode(self: *Body) *Node {
+ return self.asElement().asNode();
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(Body);
+
+ pub const Meta = struct {
+ pub const name = "HTMLBodyElement";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+};
+
+pub const Build = struct {
+ pub fn complete(node: *Node, page: *Page) !void {
+ const el = node.as(Element);
+ const on_load = el.getAttributeSafe("onload") orelse return;
+ page.window._on_load = page.js.stringToFunction(on_load) catch |err| blk: {
+ log.err(.js, "body.onload", .{ .err = err, .str = on_load });
+ break :blk null;
+ };
+ }
+};
diff --git a/src/browser/webapi/element/html/Button.zig b/src/browser/webapi/element/html/Button.zig
new file mode 100644
index 000000000..acf076e61
--- /dev/null
+++ b/src/browser/webapi/element/html/Button.zig
@@ -0,0 +1,121 @@
+// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+const js = @import("../../../js/js.zig");
+const Page = @import("../../../Page.zig");
+
+const Node = @import("../../Node.zig");
+const Element = @import("../../Element.zig");
+const HtmlElement = @import("../Html.zig");
+const Form = @import("Form.zig");
+
+const Button = @This();
+
+_proto: *HtmlElement,
+
+pub fn asElement(self: *Button) *Element {
+ return self._proto._proto;
+}
+pub fn asConstElement(self: *const Button) *const Element {
+ return self._proto._proto;
+}
+pub fn asNode(self: *Button) *Node {
+ return self.asElement().asNode();
+}
+
+pub fn getDisabled(self: *const Button) bool {
+ return self.asConstElement().getAttributeSafe("disabled") != null;
+}
+
+pub fn setDisabled(self: *Button, disabled: bool, page: *Page) !void {
+ if (disabled) {
+ try self.asElement().setAttributeSafe("disabled", "", page);
+ } else {
+ try self.asElement().removeAttribute("disabled", page);
+ }
+}
+
+pub fn getName(self: *const Button) []const u8 {
+ return self.asConstElement().getAttributeSafe("name") orelse "";
+}
+
+pub fn setName(self: *Button, name: []const u8, page: *Page) !void {
+ try self.asElement().setAttributeSafe("name", name, page);
+}
+
+pub fn getRequired(self: *const Button) bool {
+ return self.asConstElement().getAttributeSafe("required") != null;
+}
+
+pub fn setRequired(self: *Button, required: bool, page: *Page) !void {
+ if (required) {
+ try self.asElement().setAttributeSafe("required", "", page);
+ } else {
+ try self.asElement().removeAttribute("required", page);
+ }
+}
+
+pub fn getForm(self: *Button, page: *Page) ?*Form {
+ const element = self.asElement();
+
+ // 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| {
+ return form_element.is(Form);
+ }
+ // form attribute present but invalid - no form owner
+ return null;
+ }
+
+ // No form attribute - traverse ancestors looking for a