mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 23:23:28 +00:00
Compare commits
180 Commits
update-wpt
...
trusted_ty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e658b27947 | ||
|
|
eae9f9ceee | ||
|
|
d2c13ed32b | ||
|
|
bed394db80 | ||
|
|
1fe2bf5dd5 | ||
|
|
7cc332a96e | ||
|
|
6ce24b3443 | ||
|
|
1dc6e91ec4 | ||
|
|
f59e3cd4da | ||
|
|
94a30b2167 | ||
|
|
bd0fa1487f | ||
|
|
d262f017c5 | ||
|
|
a98c08c06c | ||
|
|
a2e0fd28e0 | ||
|
|
5dbdf8321a | ||
|
|
9d122bd181 | ||
|
|
09727101c1 | ||
|
|
5fc9cd7d48 | ||
|
|
7adaa53f42 | ||
|
|
cc82b1ae25 | ||
|
|
0df531a646 | ||
|
|
b1d0368479 | ||
|
|
46c6a0b4ff | ||
|
|
97d414aa00 | ||
|
|
ab8da3965b | ||
|
|
589fa4c9de | ||
|
|
f4a27af37e | ||
|
|
ca0f407b7b | ||
|
|
4810a5643e | ||
|
|
72a983f6d8 | ||
|
|
a720333c0f | ||
|
|
38c6fa9c76 | ||
|
|
eed3d27665 | ||
|
|
450e345b28 | ||
|
|
913568aba2 | ||
|
|
3c3de9d325 | ||
|
|
fada732b33 | ||
|
|
152d0fdda7 | ||
|
|
6506fa792d | ||
|
|
867c72ba90 | ||
|
|
3f6b095da4 | ||
|
|
f1d6d386c5 | ||
|
|
72944a4e5e | ||
|
|
193e012aa6 | ||
|
|
3ee17e01e1 | ||
|
|
7421fa0a33 | ||
|
|
2cdfc3f4c3 | ||
|
|
4322d8e494 | ||
|
|
73a59dcd7d | ||
|
|
3a15790847 | ||
|
|
3f31573bcb | ||
|
|
967ab18d53 | ||
|
|
0929bd217d | ||
|
|
ce832a8063 | ||
|
|
fc0281b563 | ||
|
|
f42bd02cfc | ||
|
|
52634ddeb3 | ||
|
|
ed79b4ebd8 | ||
|
|
36ca7839d6 | ||
|
|
fa5d583657 | ||
|
|
5e67f09583 | ||
|
|
8b74d96f12 | ||
|
|
769d99e7bd | ||
|
|
812f4d2699 | ||
|
|
f95defe82f | ||
|
|
226dafa9e3 | ||
|
|
6c92d50c68 | ||
|
|
384e74fe7e | ||
|
|
216f6cc8e8 | ||
|
|
333c377bc7 | ||
|
|
59b33faf61 | ||
|
|
b87003427c | ||
|
|
cb48000df7 | ||
|
|
58cc5d8d1a | ||
|
|
39799d3006 | ||
|
|
73bf4479b5 | ||
|
|
9f0f84bbee | ||
|
|
1ff422a29c | ||
|
|
8daa525cc1 | ||
|
|
76f1fcb634 | ||
|
|
2b6cf95752 | ||
|
|
a99d193b12 | ||
|
|
a3b576abd8 | ||
|
|
2261eac288 | ||
|
|
9366729d7a | ||
|
|
ad1a4fe450 | ||
|
|
9f97725894 | ||
|
|
bff3d27518 | ||
|
|
2bc1192ad3 | ||
|
|
f165131da8 | ||
|
|
afd29fef81 | ||
|
|
071a4f97e5 | ||
|
|
04c990de89 | ||
|
|
b08ffcc437 | ||
|
|
7156df8d9a | ||
|
|
1a83e69669 | ||
|
|
210d4f6aa1 | ||
|
|
b559506d4e | ||
|
|
3fec6ff5bc | ||
|
|
ce74307172 | ||
|
|
e44e68f8fc | ||
|
|
eff1341088 | ||
|
|
ddd35e3d80 | ||
|
|
265272b9d3 | ||
|
|
206e34ac80 | ||
|
|
ea556ff201 | ||
|
|
110dc751a4 | ||
|
|
46546def28 | ||
|
|
48de14ade3 | ||
|
|
f74647ccfc | ||
|
|
b92a85f0a9 | ||
|
|
853965e7a9 | ||
|
|
6f9dd8d7cd | ||
|
|
905eb1a93f | ||
|
|
7862fc7cb7 | ||
|
|
903168b3a6 | ||
|
|
5e8fcb579f | ||
|
|
fae018b4ea | ||
|
|
dc0e278a24 | ||
|
|
aaa34ab860 | ||
|
|
66638cab33 | ||
|
|
a729a61100 | ||
|
|
23b39c6a63 | ||
|
|
37467d3753 | ||
|
|
8d3a378761 | ||
|
|
3993f9c2bb | ||
|
|
b542762dce | ||
|
|
35b2ea870d | ||
|
|
b2605dd30c | ||
|
|
18b04e2999 | ||
|
|
54c2dedac0 | ||
|
|
0efa6661b8 | ||
|
|
42d0532580 | ||
|
|
8d5f7c8d3e | ||
|
|
04214200b8 | ||
|
|
99229513ba | ||
|
|
c3a992e6d4 | ||
|
|
e15c80927b | ||
|
|
e918a0bf26 | ||
|
|
35bff8cc67 | ||
|
|
0998ae753c | ||
|
|
7bb6506709 | ||
|
|
64f80312de | ||
|
|
ce2eed28c1 | ||
|
|
505fa91d7d | ||
|
|
dd7e6d3831 | ||
|
|
b086337dbe | ||
|
|
49562f50f2 | ||
|
|
884ec05a1e | ||
|
|
212d7f1865 | ||
|
|
9ab8a2cbd2 | ||
|
|
f633eddd73 | ||
|
|
f5761ee69d | ||
|
|
b8cdc0f145 | ||
|
|
b5eea2136b | ||
|
|
deded47da2 | ||
|
|
fdc0e2597d | ||
|
|
da5b0260f2 | ||
|
|
beb960b753 | ||
|
|
5cc338dedc | ||
|
|
15be42340d | ||
|
|
f10bee8cb0 | ||
|
|
eadf18821f | ||
|
|
56b1c7b11a | ||
|
|
e4513976f7 | ||
|
|
b71ea3852e | ||
|
|
ae6c29ccff | ||
|
|
1820e79617 | ||
|
|
2a95b7a37c | ||
|
|
fb95df66c9 | ||
|
|
3c76284d89 | ||
|
|
29967fde50 | ||
|
|
bd65e4084c | ||
|
|
a2a9977af6 | ||
|
|
0369b490b8 | ||
|
|
d9e5821d31 | ||
|
|
54a7df8d40 | ||
|
|
56eef2ec94 | ||
|
|
200036efc9 | ||
|
|
7fa7f4ed8a |
12
.github/actions/install/action.yml
vendored
12
.github/actions/install/action.yml
vendored
@@ -17,11 +17,11 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.1.20'
|
||||
default: 'v0.1.24'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
default: '11.1.134'
|
||||
default: '13.6.233.8'
|
||||
cache-dir:
|
||||
description: 'cache dir to use'
|
||||
required: false
|
||||
@@ -59,11 +59,11 @@ runs:
|
||||
- name: install v8
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p v8/build/${{inputs.arch}}-${{inputs.os}}/debug/ninja/obj/zig/
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/build/${{inputs.arch}}-${{inputs.os}}/debug/ninja/obj/zig/libc_v8.a
|
||||
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/build/${{inputs.arch}}-${{inputs.os}}/release/ninja/obj/zig/
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/build/${{inputs.arch}}-${{inputs.os}}/release/ninja/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: libiconv
|
||||
shell: bash
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -49,6 +50,7 @@ jobs:
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -81,6 +83,7 @@ jobs:
|
||||
OS: macos
|
||||
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -113,6 +116,7 @@ jobs:
|
||||
OS: macos
|
||||
|
||||
runs-on: macos-13
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
2
.github/workflows/cla.yml
vendored
2
.github/workflows/cla.yml
vendored
@@ -14,6 +14,8 @@ permissions:
|
||||
jobs:
|
||||
CLAAssistant:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: "CLA Assistant"
|
||||
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
|
||||
|
||||
7
.github/workflows/e2e-test.yml
vendored
7
.github/workflows/e2e-test.yml
vendored
@@ -36,6 +36,7 @@ jobs:
|
||||
name: zig build release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -62,11 +63,12 @@ jobs:
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
MAX_MEMORY: 28000
|
||||
MAX_MEMORY: 29000
|
||||
MAX_AVG_DURATION: 24
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -86,7 +88,7 @@ jobs:
|
||||
- name: run puppeteer
|
||||
run: |
|
||||
python3 -m http.server 1234 -d ./public & echo $! > PYTHON.pid
|
||||
./lightpanda serve --gc_hints & echo $! > LPD.pid
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
|
||||
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
|
||||
kill `cat LPD.pid` `cat PYTHON.pid`
|
||||
@@ -111,6 +113,7 @@ jobs:
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
63
.github/workflows/wpt.yml
vendored
63
.github/workflows/wpt.yml
vendored
@@ -7,68 +7,18 @@ env:
|
||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "tests/wpt/**"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "23 2 * * *"
|
||||
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
# see https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request
|
||||
# Since we skip the job when the PR is in draft state, we want to force CI
|
||||
# running when the PR is marked ready_for_review w/o other change.
|
||||
# see https://github.com/orgs/community/discussions/25722#discussioncomment-3248917
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# paths:
|
||||
# - ".github/**"
|
||||
# - "build.zig"
|
||||
# - "src/**/*.zig"
|
||||
# - "src/*.zig"
|
||||
# - "tests/wpt/**"
|
||||
# - "vendor/**"
|
||||
# - ".github/**"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
wpt:
|
||||
name: web platform tests
|
||||
|
||||
# Only for PR without draft.
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- run: zig build wpt -- --summary
|
||||
|
||||
# For now WPT tests doesn't pass at all.
|
||||
# We accept then to continue the job on failure.
|
||||
# TODO remove the continue-on-error when tests will pass.
|
||||
continue-on-error: true
|
||||
|
||||
wpt-json:
|
||||
name: web platform tests json output
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -97,12 +47,11 @@ jobs:
|
||||
|
||||
perf-fmt:
|
||||
name: perf-fmt
|
||||
needs: wpt-json
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: wpt
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
|
||||
1
.github/workflows/zig-fmt.yml
vendored
1
.github/workflows/zig-fmt.yml
vendored
@@ -29,6 +29,7 @@ jobs:
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: mlugg/setup-zig@v1
|
||||
|
||||
3
.github/workflows/zig-test.yml
vendored
3
.github/workflows/zig-test.yml
vendored
@@ -87,6 +87,7 @@ jobs:
|
||||
|
||||
zig-test:
|
||||
name: zig test
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
@@ -126,6 +127,8 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
|
||||
@@ -5,7 +5,7 @@ ARG ZIG=0.14.0
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG ARCH=x86_64
|
||||
ARG V8=11.1.134
|
||||
ARG ZIG_V8=v0.1.20
|
||||
ARG ZIG_V8=v0.1.24
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
apt-get install -yq xz-utils \
|
||||
@@ -57,8 +57,8 @@ RUN make install-libiconv && \
|
||||
|
||||
# download and install v8
|
||||
RUN curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
|
||||
mkdir -p v8/build/${ARCH}-linux/release/ninja/obj/zig/ && \
|
||||
mv libc_v8.a v8/build/${ARCH}-linux/release/ninja/obj/zig/libc_v8.a
|
||||
mkdir -p v8/out/linux/release/obj/zig/ && \
|
||||
mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a
|
||||
|
||||
# build release
|
||||
RUN make build
|
||||
|
||||
9
Makefile
9
Makefile
@@ -69,14 +69,19 @@ build:
|
||||
## Build in debug mode
|
||||
build-dev:
|
||||
@printf "\e[36mBuilding (debug)...\e[0m\n"
|
||||
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) -Dlog_level=debug || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@printf "\e[33mBuild OK\e[0m\n"
|
||||
|
||||
## Run the server in debug mode
|
||||
## Run the server in release mode
|
||||
run: build
|
||||
@printf "\e[36mRunning...\e[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Run the server in debug mode
|
||||
run-debug: build-dev
|
||||
@printf "\e[36mRunning...\e[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Run a JS shell in debug mode
|
||||
shell:
|
||||
@printf "\e[36mBuilding shell...\e[0m\n"
|
||||
|
||||
@@ -18,7 +18,7 @@ Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
- Javascript execution
|
||||
- Support of Web APIs (partial, WIP)
|
||||
- Compatible with Playwright, Puppeteer through CDP (WIP)
|
||||
- Compatible with Playwright[^1], Puppeteer through CDP (WIP)
|
||||
|
||||
Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
|
||||
@@ -36,6 +36,9 @@ Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
|
||||
See [benchmark details](https://github.com/lightpanda-io/demo)._
|
||||
|
||||
[^1]: **Playwright support disclaimer:**
|
||||
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Install from the nightly builds
|
||||
@@ -161,7 +164,7 @@ For Debian/Ubuntu based Linux:
|
||||
sudo apt install xz-utils \
|
||||
python3 ca-certificates git \
|
||||
pkg-config libglib2.0-dev \
|
||||
gperf libexpat1-dev \
|
||||
gperf libexpat1-dev unzip rsync \
|
||||
cmake clang
|
||||
```
|
||||
|
||||
|
||||
64
build.zig
64
build.zig
@@ -44,6 +44,18 @@ pub fn build(b: *std.Build) !void {
|
||||
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
|
||||
);
|
||||
|
||||
opts.addOption(
|
||||
std.log.Level,
|
||||
"log_level",
|
||||
b.option(std.log.Level, "log_level", "The log level") orelse std.log.Level.info,
|
||||
);
|
||||
|
||||
opts.addOption(
|
||||
bool,
|
||||
"log_unknown_properties",
|
||||
b.option(bool, "log_unknown_properties", "Log access to unknown properties") orelse false,
|
||||
);
|
||||
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
@@ -158,29 +170,41 @@ fn common(b: *std.Build, opts: *std.Build.Step.Options, step: *std.Build.Step.Co
|
||||
mod.addImport("v8", v8_mod);
|
||||
}
|
||||
|
||||
const mode_str: []const u8 = if (mod.optimize.? == .Debug) "debug" else "release";
|
||||
mod.link_libcpp = true;
|
||||
|
||||
// FIXME: we are tied to native v8 builds, currently:
|
||||
// - aarch64-macos
|
||||
// - x86_64-linux
|
||||
const os = target.result.os.tag;
|
||||
const arch = target.result.cpu.arch;
|
||||
switch (os) {
|
||||
.macos => {},
|
||||
.linux => {
|
||||
// TODO: why do we need it? It should be linked already when we built v8
|
||||
mod.link_libcpp = true;
|
||||
},
|
||||
else => return error.OsNotSupported,
|
||||
{
|
||||
const release_dir = if (mod.optimize.? == .Debug) "debug" else "release";
|
||||
const os = switch (target.result.os.tag) {
|
||||
.linux => "linux",
|
||||
.macos => "macos",
|
||||
else => return error.UnsupportedPlatform,
|
||||
};
|
||||
var lib_path = try std.fmt.allocPrint(
|
||||
mod.owner.allocator,
|
||||
"v8/out/{s}/{s}/obj/zig/libc_v8.a",
|
||||
.{ os, release_dir },
|
||||
);
|
||||
std.fs.cwd().access(lib_path, .{}) catch {
|
||||
// legacy path
|
||||
lib_path = try std.fmt.allocPrint(
|
||||
mod.owner.allocator,
|
||||
"v8/out/{s}/obj/zig/libc_v8.a",
|
||||
.{release_dir},
|
||||
);
|
||||
};
|
||||
mod.addObjectFile(mod.owner.path(lib_path));
|
||||
}
|
||||
|
||||
const lib_path = try std.fmt.allocPrint(
|
||||
mod.owner.allocator,
|
||||
"v8/build/{s}-{s}/{s}/ninja/obj/zig/libc_v8.a",
|
||||
.{ @tagName(arch), @tagName(os), mode_str },
|
||||
);
|
||||
mod.addObjectFile(mod.owner.path(lib_path));
|
||||
mod.addImport("build_info", opts.createModule());
|
||||
switch (target.result.os.tag) {
|
||||
.macos => {
|
||||
// v8 has a dependency, abseil-cpp, which, on Mac, uses CoreFoundation
|
||||
mod.addSystemFrameworkPath(.{ .cwd_relative = "/System/Library/Frameworks" });
|
||||
mod.linkFramework("CoreFoundation", .{});
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
mod.addImport("build_config", opts.createModule());
|
||||
}
|
||||
|
||||
fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
|
||||
},
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/f0c7eaaffe39f2f1a224fbe97e550daca0ca1801.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH62T4IADchAHFgo4nx79w1VedNDhIVErtSNgup-Tk",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/e38cb27ddb044c6afbf8a938b293721b9804405e.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6_GzAwCaz83JWuw3sepOGq0I7C_CmfOwA1Gb9q3y",
|
||||
},
|
||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
||||
|
||||
@@ -28,8 +28,8 @@ pub const App = struct {
|
||||
|
||||
pub const Config = struct {
|
||||
run_mode: RunMode,
|
||||
gc_hints: bool = false,
|
||||
tls_verify_host: bool = true,
|
||||
http_proxy: ?std.Uri = null,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator, config: Config) !*App {
|
||||
@@ -54,6 +54,7 @@ pub const App = struct {
|
||||
.app_dir_path = app_dir_path,
|
||||
.notification = notification,
|
||||
.http_client = try HttpClient.init(allocator, 5, .{
|
||||
.http_proxy = config.http_proxy,
|
||||
.tls_verify_host = config.tls_verify_host,
|
||||
}),
|
||||
.config = config,
|
||||
|
||||
@@ -17,33 +17,16 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const Dump = @import("dump.zig");
|
||||
const Mime = @import("mime.zig").Mime;
|
||||
const DataURI = @import("datauri.zig").DataURI;
|
||||
const parser = @import("netsurf.zig");
|
||||
|
||||
const Window = @import("html/window.zig").Window;
|
||||
const Walker = @import("dom/walker.zig").WalkerDepthFirst;
|
||||
|
||||
const Env = @import("env.zig").Env;
|
||||
const App = @import("../app.zig").App;
|
||||
const Loop = @import("../runtime/loop.zig").Loop;
|
||||
|
||||
const URL = @import("../url.zig").URL;
|
||||
|
||||
const http = @import("../http/client.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
const SessionState = @import("env.zig").SessionState;
|
||||
const Session = @import("session.zig").Session;
|
||||
const Notification = @import("../notification.zig").Notification;
|
||||
|
||||
const polyfill = @import("polyfill/polyfill.zig");
|
||||
|
||||
const log = std.log.scoped(.browser);
|
||||
const http = @import("../http/client.zig");
|
||||
|
||||
// Browser is an instance of the browser.
|
||||
// You can create multiple browser instances.
|
||||
@@ -55,14 +38,14 @@ pub const Browser = struct {
|
||||
allocator: Allocator,
|
||||
http_client: *http.Client,
|
||||
page_arena: ArenaAllocator,
|
||||
session_arena: ArenaAllocator,
|
||||
transfer_arena: ArenaAllocator,
|
||||
notification: *Notification,
|
||||
|
||||
pub fn init(app: *App) !Browser {
|
||||
const allocator = app.allocator;
|
||||
|
||||
const env = try Env.init(allocator, .{
|
||||
.gc_hints = app.config.gc_hints,
|
||||
});
|
||||
const env = try Env.init(allocator, .{});
|
||||
errdefer env.deinit();
|
||||
|
||||
const notification = try Notification.init(allocator, app.notification);
|
||||
@@ -76,6 +59,8 @@ pub const Browser = struct {
|
||||
.notification = notification,
|
||||
.http_client = &app.http_client,
|
||||
.page_arena = ArenaAllocator.init(allocator),
|
||||
.session_arena = ArenaAllocator.init(allocator),
|
||||
.transfer_arena = ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,6 +68,8 @@ pub const Browser = struct {
|
||||
self.closeSession();
|
||||
self.env.deinit();
|
||||
self.page_arena.deinit();
|
||||
self.session_arena.deinit();
|
||||
self.transfer_arena.deinit();
|
||||
self.notification.deinit();
|
||||
}
|
||||
|
||||
@@ -98,6 +85,8 @@ pub const Browser = struct {
|
||||
if (self.session) |*session| {
|
||||
session.deinit();
|
||||
self.session = null;
|
||||
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
|
||||
self.env.lowMemoryNotification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,762 +95,6 @@ pub const Browser = struct {
|
||||
}
|
||||
};
|
||||
|
||||
// Session is like a browser's tab.
|
||||
// It owns the js env and the loader for all the pages of the session.
|
||||
// You can create successively multiple pages for a session, but you must
|
||||
// deinit a page before running another one.
|
||||
pub const Session = struct {
|
||||
browser: *Browser,
|
||||
|
||||
// Used to create our Inspector and in the BrowserContext.
|
||||
arena: ArenaAllocator,
|
||||
|
||||
executor: Env.Executor,
|
||||
storage_shed: storage.Shed,
|
||||
cookie_jar: storage.CookieJar,
|
||||
|
||||
page: ?Page = null,
|
||||
|
||||
fn init(self: *Session, browser: *Browser) !void {
|
||||
var executor = try browser.env.newExecutor();
|
||||
errdefer executor.deinit();
|
||||
|
||||
const allocator = browser.app.allocator;
|
||||
self.* = .{
|
||||
.browser = browser,
|
||||
.executor = executor,
|
||||
.arena = ArenaAllocator.init(allocator),
|
||||
.storage_shed = storage.Shed.init(allocator),
|
||||
.cookie_jar = storage.CookieJar.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *Session) void {
|
||||
if (self.page != null) {
|
||||
self.removePage();
|
||||
}
|
||||
self.arena.deinit();
|
||||
self.cookie_jar.deinit();
|
||||
self.storage_shed.deinit();
|
||||
self.executor.deinit();
|
||||
}
|
||||
|
||||
// NOTE: the caller is not the owner of the returned value,
|
||||
// the pointer on Page is just returned as a convenience
|
||||
pub fn createPage(self: *Session) !*Page {
|
||||
std.debug.assert(self.page == null);
|
||||
|
||||
const page_arena = &self.browser.page_arena;
|
||||
_ = page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, page_arena.allocator(), self);
|
||||
|
||||
// start JS env
|
||||
log.debug("start new js scope", .{});
|
||||
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
|
||||
self.browser.notification.dispatch(.page_created, page);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
pub fn removePage(self: *Session) void {
|
||||
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
|
||||
self.browser.notification.dispatch(.page_remove, .{});
|
||||
|
||||
std.debug.assert(self.page != null);
|
||||
// Reset all existing callbacks.
|
||||
self.browser.app.loop.reset();
|
||||
self.executor.endScope();
|
||||
self.page = null;
|
||||
|
||||
// clear netsurf memory arena.
|
||||
parser.deinit();
|
||||
}
|
||||
|
||||
pub fn currentPage(self: *Session) ?*Page {
|
||||
return &(self.page orelse return null);
|
||||
}
|
||||
|
||||
fn pageNavigate(self: *Session, url_string: []const u8) !void {
|
||||
// currently, this is only called from the page, so let's hope
|
||||
// it isn't null!
|
||||
std.debug.assert(self.page != null);
|
||||
|
||||
// can't use the page arena, because we're about to reset it
|
||||
// and don't want to use the session's arena, because that'll start to
|
||||
// look like a leak if we navigate from page to page a lot.
|
||||
var buf: [1024]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&buf);
|
||||
const url = try self.page.?.url.resolve(fba.allocator(), url_string);
|
||||
|
||||
self.removePage();
|
||||
var page = try self.createPage();
|
||||
return page.navigate(url, .{
|
||||
.reason = .anchor,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Page navigates to an url.
|
||||
// You can navigates multiple urls with the same page, but you have to call
|
||||
// end() to stop the previous navigation before starting a new one.
|
||||
// The page handle all its memory in an arena allocator. The arena is reseted
|
||||
// when end() is called.
|
||||
pub const Page = struct {
|
||||
session: *Session,
|
||||
|
||||
// an arena with a lifetime for the entire duration of the page
|
||||
arena: Allocator,
|
||||
|
||||
// Gets injected into any WebAPI method that needs it
|
||||
state: SessionState,
|
||||
|
||||
// Serves are the root object of our JavaScript environment
|
||||
window: Window,
|
||||
|
||||
doc: ?*parser.Document,
|
||||
|
||||
// The URL of the page
|
||||
url: URL,
|
||||
|
||||
raw_data: ?[]const u8,
|
||||
|
||||
renderer: FlatRenderer,
|
||||
|
||||
microtask_node: Loop.CallbackNode,
|
||||
|
||||
window_clicked_event_node: parser.EventNode,
|
||||
|
||||
scope: *Env.Scope,
|
||||
|
||||
// current_script is the script currently evaluated by the page.
|
||||
// current_script could by fetch module to resolve module's url to fetch.
|
||||
current_script: ?*const Script = null,
|
||||
|
||||
fn init(self: *Page, arena: Allocator, session: *Session) !void {
|
||||
const browser = session.browser;
|
||||
self.* = .{
|
||||
.window = .{},
|
||||
.arena = arena,
|
||||
.doc = null,
|
||||
.raw_data = null,
|
||||
.url = URL.empty,
|
||||
.session = session,
|
||||
.renderer = FlatRenderer.init(arena),
|
||||
.microtask_node = .{ .func = microtaskCallback },
|
||||
.window_clicked_event_node = .{ .func = windowClicked },
|
||||
.state = .{
|
||||
.arena = arena,
|
||||
.document = null,
|
||||
.url = &self.url,
|
||||
.renderer = &self.renderer,
|
||||
.loop = browser.app.loop,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.http_client = browser.http_client,
|
||||
},
|
||||
.scope = try session.executor.startScope(&self.window, &self.state, self, true),
|
||||
};
|
||||
|
||||
// load polyfills
|
||||
try polyfill.load(self.arena, self.scope);
|
||||
|
||||
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
|
||||
}
|
||||
|
||||
fn microtaskCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
const self: *Page = @fieldParentPtr("microtask_node", node);
|
||||
self.session.browser.runMicrotasks();
|
||||
repeat_delay.* = 1 * std.time.ns_per_ms;
|
||||
}
|
||||
|
||||
// dump writes the page content into the given file.
|
||||
pub fn dump(self: *const Page, out: std.fs.File) !void {
|
||||
// if no HTML document pointer available, dump the data content only.
|
||||
if (self.doc == null) {
|
||||
// no data loaded, nothing to do.
|
||||
if (self.raw_data == null) return;
|
||||
return try out.writeAll(self.raw_data.?);
|
||||
}
|
||||
|
||||
// if the page has a pointer to a document, dumps the HTML.
|
||||
try Dump.writeHTML(self.doc.?, out);
|
||||
}
|
||||
|
||||
pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) ![]const u8 {
|
||||
const self: *Page = @ptrCast(@alignCast(ctx));
|
||||
|
||||
log.debug("fetch module: specifier: {s}", .{specifier});
|
||||
return try self.fetchData(
|
||||
specifier,
|
||||
if (self.current_script) |s| s.src else null,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn wait(self: *Page) !void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(self.scope);
|
||||
defer try_catch.deinit();
|
||||
|
||||
try self.session.browser.app.loop.run();
|
||||
|
||||
if (try_catch.hasCaught() == false) {
|
||||
log.debug("wait: OK", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = (try try_catch.err(self.arena)) orelse "unknown";
|
||||
log.info("wait error: {s}", .{msg});
|
||||
}
|
||||
|
||||
pub fn origin(self: *const Page, arena: Allocator) ![]const u8 {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
try self.url.origin(arr.writer(arena));
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
// spec reference: https://html.spec.whatwg.org/#document-lifecycle
|
||||
pub fn navigate(self: *Page, request_url: URL, opts: NavigateOpts) !void {
|
||||
const arena = self.arena;
|
||||
const session = self.session;
|
||||
|
||||
log.debug("starting GET {s}", .{request_url});
|
||||
|
||||
// if the url is about:blank, nothing to do.
|
||||
if (std.mem.eql(u8, "about:blank", request_url.raw)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// we don't clone url, because we're going to replace self.url
|
||||
// later in this function, with the final request url (since we might
|
||||
// redirect)
|
||||
self.url = request_url;
|
||||
|
||||
// load the data
|
||||
var request = try self.newHTTPRequest(.GET, &self.url, .{ .navigation = true });
|
||||
defer request.deinit();
|
||||
|
||||
session.browser.notification.dispatch(.page_navigate, &.{
|
||||
.url = &self.url,
|
||||
.reason = opts.reason,
|
||||
.timestamp = timestamp(),
|
||||
});
|
||||
|
||||
var response = try request.sendSync(.{});
|
||||
|
||||
// would be different than self.url in the case of a redirect
|
||||
self.url = try URL.fromURI(arena, request.uri);
|
||||
|
||||
const header = response.header;
|
||||
try session.cookie_jar.populateFromResponse(&self.url.uri, &header);
|
||||
|
||||
// TODO handle fragment in url.
|
||||
try self.window.replaceLocation(.{ .url = try self.url.toWebApi(arena) });
|
||||
|
||||
log.info("GET {any} {d}", .{ self.url, header.status });
|
||||
|
||||
const content_type = header.get("content-type");
|
||||
|
||||
const mime: Mime = blk: {
|
||||
if (content_type) |ct| {
|
||||
break :blk try Mime.parse(arena, ct);
|
||||
}
|
||||
break :blk Mime.sniff(try response.peek());
|
||||
} orelse .unknown;
|
||||
|
||||
if (mime.isHTML()) {
|
||||
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
|
||||
} else {
|
||||
log.info("non-HTML document: {s}", .{content_type orelse "null"});
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
while (try response.next()) |data| {
|
||||
try arr.appendSlice(arena, try arena.dupe(u8, data));
|
||||
}
|
||||
// save the body into the page.
|
||||
self.raw_data = arr.items;
|
||||
}
|
||||
|
||||
session.browser.notification.dispatch(.page_navigated, &.{
|
||||
.url = &self.url,
|
||||
.timestamp = timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8) !void {
|
||||
const arena = self.arena;
|
||||
|
||||
// start netsurf memory arena.
|
||||
try parser.init();
|
||||
|
||||
log.debug("parse html with charset {s}", .{charset});
|
||||
|
||||
const ccharset = try arena.dupeZ(u8, charset);
|
||||
|
||||
const html_doc = try parser.documentHTMLParse(reader, ccharset);
|
||||
const doc = parser.documentHTMLToDocument(html_doc);
|
||||
|
||||
// save a document's pointer in the page.
|
||||
self.doc = doc;
|
||||
|
||||
const document_element = (try parser.documentGetDocumentElement(doc)) orelse return error.DocumentElementError;
|
||||
try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Element, document_element),
|
||||
"click",
|
||||
&self.window_clicked_event_node,
|
||||
false,
|
||||
);
|
||||
|
||||
// TODO set document.readyState to interactive
|
||||
// https://html.spec.whatwg.org/#reporting-document-loading-status
|
||||
|
||||
// inject the URL to the document including the fragment.
|
||||
try parser.documentSetDocumentURI(doc, self.url.raw);
|
||||
|
||||
// TODO set the referrer to the document.
|
||||
try self.window.replaceDocument(html_doc);
|
||||
self.window.setStorageShelf(
|
||||
try self.session.storage_shed.getOrPut(try self.origin(self.arena)),
|
||||
);
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
|
||||
// update the sessions state
|
||||
self.state.document = html_doc;
|
||||
|
||||
// browse the DOM tree to retrieve scripts
|
||||
// TODO execute the synchronous scripts during the HTL parsing.
|
||||
// TODO fetch the script resources concurrently but execute them in the
|
||||
// declaration order for synchronous ones.
|
||||
|
||||
// sasync stores scripts which can be run asynchronously.
|
||||
// for now they are just run after the non-async one in order to
|
||||
// dispatch DOMContentLoaded the sooner as possible.
|
||||
var sasync: std.ArrayListUnmanaged(Script) = .{};
|
||||
|
||||
const root = parser.documentToNode(doc);
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = try walker.get_next(root, next) orelse break;
|
||||
|
||||
// ignore non-elements nodes.
|
||||
if (try parser.nodeType(next.?) != .element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const e = parser.nodeToElement(next.?);
|
||||
|
||||
// ignore non-js script.
|
||||
const script = try Script.init(e) orelse continue;
|
||||
if (script.kind == .unknown) continue;
|
||||
|
||||
// Ignore the defer attribute b/c we analyze all script
|
||||
// after the document has been parsed.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer
|
||||
|
||||
// TODO use fetchpriority
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#fetchpriority
|
||||
|
||||
// > async
|
||||
// > For classic scripts, if the async attribute is present,
|
||||
// > then the classic script will be fetched in parallel to
|
||||
// > parsing and evaluated as soon as it is available.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#async
|
||||
if (script.is_async) {
|
||||
try sasync.append(arena, script);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO handle for attribute
|
||||
// TODO handle event attribute
|
||||
|
||||
// TODO defer
|
||||
// > This Boolean attribute is set to indicate to a browser
|
||||
// > that the script is meant to be executed after the
|
||||
// > document has been parsed, but before firing
|
||||
// > DOMContentLoaded.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer
|
||||
// defer allow us to load a script w/o blocking the rest of
|
||||
// evaluations.
|
||||
|
||||
// > Scripts without async, defer or type="module"
|
||||
// > attributes, as well as inline scripts without the
|
||||
// > type="module" attribute, are fetched and executed
|
||||
// > immediately before the browser continues to parse the
|
||||
// > page.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, @ptrCast(e));
|
||||
self.evalScript(&script) catch |err| log.warn("evaljs: {any}", .{err});
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, null);
|
||||
}
|
||||
|
||||
// TODO wait for deferred scripts
|
||||
|
||||
// dispatch DOMContentLoaded before the transition to "complete",
|
||||
// at the point where all subresources apart from async script elements
|
||||
// have loaded.
|
||||
// https://html.spec.whatwg.org/#reporting-document-loading-status
|
||||
const evt = try parser.eventCreate();
|
||||
defer parser.eventDestroy(evt);
|
||||
|
||||
try parser.eventInit(evt, "DOMContentLoaded", .{ .bubbles = true, .cancelable = true });
|
||||
_ = try parser.eventTargetDispatchEvent(parser.toEventTarget(parser.DocumentHTML, html_doc), evt);
|
||||
|
||||
// eval async scripts.
|
||||
for (sasync.items) |s| {
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, @ptrCast(s.element));
|
||||
self.evalScript(&s) catch |err| log.warn("evaljs: {any}", .{err});
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, null);
|
||||
}
|
||||
|
||||
// TODO wait for async scripts
|
||||
|
||||
// TODO set document.readyState to complete
|
||||
|
||||
// dispatch window.load event
|
||||
const loadevt = try parser.eventCreate();
|
||||
defer parser.eventDestroy(loadevt);
|
||||
|
||||
try parser.eventInit(loadevt, "load", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(Window, &self.window),
|
||||
loadevt,
|
||||
);
|
||||
}
|
||||
|
||||
// evalScript evaluates the src in priority.
|
||||
// if no src is present, we evaluate the text source.
|
||||
// https://html.spec.whatwg.org/multipage/scripting.html#script-processing-model
|
||||
fn evalScript(self: *Page, s: *const Script) !void {
|
||||
self.current_script = s;
|
||||
defer self.current_script = null;
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
|
||||
const opt_src = try parser.elementGetAttribute(s.element, "src");
|
||||
if (opt_src) |src| {
|
||||
log.debug("starting GET {s}", .{src});
|
||||
|
||||
self.fetchScript(s) catch |err| {
|
||||
switch (err) {
|
||||
FetchError.BadStatusCode => return err,
|
||||
|
||||
// TODO If el's result is null, then fire an event named error at
|
||||
// el, and return.
|
||||
FetchError.NoBody => return,
|
||||
|
||||
FetchError.JsErr => {}, // nothing to do here.
|
||||
else => return err,
|
||||
}
|
||||
};
|
||||
|
||||
// TODO If el's from an external file is true, then fire an event
|
||||
// named load at el.
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO handle charset attribute
|
||||
const opt_text = try parser.nodeTextContent(parser.elementToNode(s.element));
|
||||
if (opt_text) |text| {
|
||||
try s.eval(self, text);
|
||||
return;
|
||||
}
|
||||
|
||||
// nothing has been loaded.
|
||||
// TODO If el's result is null, then fire an event named error at
|
||||
// el, and return.
|
||||
}
|
||||
|
||||
const FetchError = error{
|
||||
BadStatusCode,
|
||||
NoBody,
|
||||
JsErr,
|
||||
};
|
||||
|
||||
// fetchData returns the data corresponding to the src target.
|
||||
// It resolves src using the page's uri.
|
||||
// If a base path is given, src is resolved according to the base first.
|
||||
// the caller owns the returned string
|
||||
fn fetchData(self: *const Page, src: []const u8, base: ?[]const u8) ![]const u8 {
|
||||
log.debug("starting fetch {s}", .{src});
|
||||
|
||||
const arena = self.arena;
|
||||
|
||||
// Handle data URIs.
|
||||
if (try DataURI.parse(arena, src)) |data_uri| {
|
||||
return data_uri.data;
|
||||
}
|
||||
|
||||
var res_src = src;
|
||||
|
||||
// if a base path is given, we resolve src using base.
|
||||
if (base) |_base| {
|
||||
const dir = std.fs.path.dirname(_base);
|
||||
if (dir) |_dir| {
|
||||
res_src = try std.fs.path.resolve(arena, &.{ _dir, src });
|
||||
}
|
||||
}
|
||||
var origin_url = &self.url;
|
||||
const url = try origin_url.resolve(arena, res_src);
|
||||
|
||||
var request = try self.newHTTPRequest(.GET, &url, .{
|
||||
.origin_uri = &origin_url.uri,
|
||||
.navigation = false,
|
||||
});
|
||||
defer request.deinit();
|
||||
|
||||
var response = try request.sendSync(.{});
|
||||
var header = response.header;
|
||||
try self.session.cookie_jar.populateFromResponse(&url.uri, &header);
|
||||
|
||||
log.info("fetch {any}: {d}", .{ url, header.status });
|
||||
|
||||
if (header.status != 200) {
|
||||
return FetchError.BadStatusCode;
|
||||
}
|
||||
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
while (try response.next()) |data| {
|
||||
try arr.appendSlice(arena, try arena.dupe(u8, data));
|
||||
}
|
||||
|
||||
// TODO check content-type
|
||||
|
||||
// check no body
|
||||
if (arr.items.len == 0) {
|
||||
return FetchError.NoBody;
|
||||
}
|
||||
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
fn fetchScript(self: *Page, s: *const Script) !void {
|
||||
const body = try self.fetchData(s.src, null);
|
||||
try s.eval(self, body);
|
||||
}
|
||||
|
||||
fn newHTTPRequest(self: *const Page, method: http.Request.Method, url: *const URL, opts: storage.cookie.LookupOpts) !http.Request {
|
||||
var request = try self.state.http_client.request(method, &url.uri);
|
||||
errdefer request.deinit();
|
||||
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
try self.state.cookie_jar.forRequest(&url.uri, arr.writer(self.arena), opts);
|
||||
|
||||
if (arr.items.len > 0) {
|
||||
try request.addHeader("Cookie", arr.items, .{});
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
pub const MouseEvent = struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
type: Type,
|
||||
|
||||
const Type = enum {
|
||||
pressed,
|
||||
released,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn mouseEvent(self: *Page, me: MouseEvent) !void {
|
||||
if (me.type != .pressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = self.renderer.getElementAtPosition(me.x, me.y) orelse return;
|
||||
|
||||
const event = try parser.mouseEventCreate();
|
||||
defer parser.mouseEventDestroy(event);
|
||||
try parser.mouseEventInit(event, "click", .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.x = me.x,
|
||||
.y = me.y,
|
||||
});
|
||||
_ = try parser.elementDispatchEvent(element, @ptrCast(event));
|
||||
}
|
||||
|
||||
fn windowClicked(node: *parser.EventNode, event: *parser.Event) void {
|
||||
const self: *Page = @fieldParentPtr("window_clicked_event_node", node);
|
||||
self._windowClicked(event) catch |err| {
|
||||
log.err("window click handler: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn _windowClicked(self: *Page, event: *parser.Event) !void {
|
||||
const target = (try parser.eventTarget(event)) orelse return;
|
||||
|
||||
const node = parser.eventTargetToNode(target);
|
||||
if (try parser.nodeType(node) != .element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const html_element: *parser.ElementHTML = @ptrCast(node);
|
||||
switch (try parser.elementHTMLGetTagType(html_element)) {
|
||||
.a => {
|
||||
const element: *parser.Element = @ptrCast(node);
|
||||
const href = (try parser.elementGetAttribute(element, "href")) orelse return;
|
||||
return self.session.pageNavigate(href);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const Script = struct {
|
||||
element: *parser.Element,
|
||||
kind: Kind,
|
||||
is_async: bool,
|
||||
|
||||
src: []const u8,
|
||||
|
||||
const Kind = enum {
|
||||
unknown,
|
||||
javascript,
|
||||
module,
|
||||
};
|
||||
|
||||
fn init(e: *parser.Element) !?Script {
|
||||
// ignore non-script tags
|
||||
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(e)));
|
||||
if (tag != .script) return null;
|
||||
|
||||
return .{
|
||||
.element = e,
|
||||
.kind = parseKind(try parser.elementGetAttribute(e, "type")),
|
||||
.is_async = try parser.elementGetAttribute(e, "async") != null,
|
||||
.src = try parser.elementGetAttribute(e, "src") orelse "inline",
|
||||
};
|
||||
}
|
||||
|
||||
// > type
|
||||
// > Attribute is not set (default), an empty string, or a JavaScript MIME
|
||||
// > type indicates that the script is a "classic script", containing
|
||||
// > JavaScript code.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attribute_is_not_set_default_an_empty_string_or_a_javascript_mime_type
|
||||
fn parseKind(stype: ?[]const u8) Kind {
|
||||
if (stype == null or stype.?.len == 0) return .javascript;
|
||||
if (std.mem.eql(u8, stype.?, "application/javascript")) return .javascript;
|
||||
if (std.mem.eql(u8, stype.?, "text/javascript")) return .javascript;
|
||||
if (std.mem.eql(u8, stype.?, "module")) return .module;
|
||||
|
||||
return .unknown;
|
||||
}
|
||||
|
||||
fn eval(self: Script, page: *Page, body: []const u8) !void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(page.scope);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const res = switch (self.kind) {
|
||||
.unknown => return error.UnknownScript,
|
||||
.javascript => page.scope.exec(body, self.src),
|
||||
.module => page.scope.module(body, self.src),
|
||||
} catch {
|
||||
if (try try_catch.err(page.arena)) |msg| {
|
||||
log.info("eval script {s}: {s}", .{ self.src, msg });
|
||||
}
|
||||
return FetchError.JsErr;
|
||||
};
|
||||
|
||||
if (builtin.mode == .Debug) {
|
||||
const msg = try res.toString(page.arena);
|
||||
log.debug("eval script {s}: {s}", .{ self.src, msg });
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
pub const NavigateReason = enum {
|
||||
anchor,
|
||||
address_bar,
|
||||
};
|
||||
|
||||
const NavigateOpts = struct {
|
||||
reason: NavigateReason = .address_bar,
|
||||
};
|
||||
|
||||
// provide very poor abstration to the rest of the code. In theory, we can change
|
||||
// the FlatRenderer to a different implementation, and it'll all just work.
|
||||
pub const Renderer = FlatRenderer;
|
||||
|
||||
// This "renderer" positions elements in a single row in an unspecified order.
|
||||
// The important thing is that elements have a consistent position/index within
|
||||
// that row, which can be turned into a rectangle.
|
||||
const FlatRenderer = struct {
|
||||
allocator: Allocator,
|
||||
|
||||
// key is a @ptrFromInt of the element
|
||||
// value is the index position
|
||||
positions: std.AutoHashMapUnmanaged(u64, u32),
|
||||
|
||||
// given an index, get the element
|
||||
elements: std.ArrayListUnmanaged(u64),
|
||||
|
||||
const Element = @import("dom/element.zig").Element;
|
||||
|
||||
// we expect allocator to be an arena
|
||||
pub fn init(allocator: Allocator) FlatRenderer {
|
||||
return .{
|
||||
.elements = .{},
|
||||
.positions = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
|
||||
var elements = &self.elements;
|
||||
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
|
||||
var x: u32 = gop.value_ptr.*;
|
||||
if (gop.found_existing == false) {
|
||||
try elements.append(self.allocator, @intFromPtr(e));
|
||||
x = @intCast(elements.items.len);
|
||||
gop.value_ptr.* = x;
|
||||
}
|
||||
|
||||
return .{
|
||||
.x = @floatFromInt(x),
|
||||
.y = 0.0,
|
||||
.width = 1.0,
|
||||
.height = 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
|
||||
return .{
|
||||
.x = 0.0,
|
||||
.y = 0.0,
|
||||
.width = @floatFromInt(self.width()),
|
||||
.height = @floatFromInt(self.height()),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn width(self: *const FlatRenderer) u32 {
|
||||
return @intCast(self.elements.items.len + 1); // +1 since x starts at 1 (use len after append)
|
||||
}
|
||||
|
||||
pub fn height(_: *const FlatRenderer) u32 {
|
||||
return 1;
|
||||
}
|
||||
|
||||
pub fn getElementAtPosition(self: *const FlatRenderer, x: i32, y: i32) ?*parser.Element {
|
||||
if (y != 1 or x < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = self.elements.items;
|
||||
return if (x < elements.len) @ptrFromInt(elements[@intCast(x)]) else null;
|
||||
}
|
||||
};
|
||||
|
||||
fn timestamp() u32 {
|
||||
const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable;
|
||||
return @intCast(ts.sec);
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "Browser" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
|
||||
@@ -29,6 +29,8 @@ const PseudoClass = selector.PseudoClass;
|
||||
const AttributeOP = selector.AttributeOP;
|
||||
const Combinator = selector.Combinator;
|
||||
|
||||
const REPLACEMENT_CHARACTER = &.{ 239, 191, 189 };
|
||||
|
||||
pub const ParseError = error{
|
||||
ExpectedSelector,
|
||||
ExpectedIdentifier,
|
||||
@@ -217,22 +219,31 @@ pub const Parser = struct {
|
||||
// parseName parses a name (which is like an identifier, but doesn't have
|
||||
// extra restrictions on the first character).
|
||||
fn parseName(p: *Parser, w: anytype) ParseError!void {
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
var i = p.i;
|
||||
var ok = false;
|
||||
|
||||
while (i < p.s.len) {
|
||||
const c = p.s[i];
|
||||
while (i < sel_len) {
|
||||
const c = sel[i];
|
||||
|
||||
if (nameChar(c)) {
|
||||
const start = i;
|
||||
while (i < p.s.len and nameChar(p.s[i])) i += 1;
|
||||
w.writeAll(p.s[start..i]) catch return ParseError.WriteError;
|
||||
while (i < sel_len and nameChar(sel[i])) i += 1;
|
||||
w.writeAll(sel[start..i]) catch return ParseError.WriteError;
|
||||
ok = true;
|
||||
} else if (c == '\\') {
|
||||
p.i = i;
|
||||
try p.parseEscape(w);
|
||||
i = p.i;
|
||||
ok = true;
|
||||
} else if (c == 0) {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
i += 1;
|
||||
if (i == sel_len) {
|
||||
ok = true;
|
||||
}
|
||||
} else {
|
||||
// default:
|
||||
break;
|
||||
@@ -246,33 +257,52 @@ pub const Parser = struct {
|
||||
// parseEscape parses a backslash escape.
|
||||
// The returned string is owned by the caller.
|
||||
fn parseEscape(p: *Parser, w: anytype) ParseError!void {
|
||||
if (p.s.len < p.i + 2 or p.s[p.i] != '\\') {
|
||||
return ParseError.InvalidEscape;
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
if (sel_len < p.i + 2 or sel[p.i] != '\\') {
|
||||
p.i += 1;
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = p.i + 1;
|
||||
const c = p.s[start];
|
||||
if (ascii.isWhitespace(c)) return ParseError.EscapeLineEndingOutsideString;
|
||||
const c = sel[start];
|
||||
|
||||
// unicode escape (hex)
|
||||
if (ascii.isHex(c)) {
|
||||
var i: usize = start;
|
||||
while (i < start + 6 and i < p.s.len and ascii.isHex(p.s[i])) {
|
||||
while (i < start + 6 and i < sel_len and ascii.isHex(sel[i])) {
|
||||
i += 1;
|
||||
}
|
||||
const v = std.fmt.parseUnsigned(u21, p.s[start..i], 16) catch return ParseError.InvalidUnicode;
|
||||
if (p.s.len > i) {
|
||||
switch (p.s[i]) {
|
||||
'\r' => {
|
||||
i += 1;
|
||||
if (p.s.len > i and p.s[i] == '\n') i += 1;
|
||||
},
|
||||
' ', '\t', '\n', std.ascii.control_code.ff => i += 1,
|
||||
else => {},
|
||||
|
||||
const v = std.fmt.parseUnsigned(u21, sel[start..i], 16) catch {
|
||||
p.i = i;
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
};
|
||||
|
||||
if (sel_len >= i) {
|
||||
if (sel_len > i) {
|
||||
switch (sel[i]) {
|
||||
'\r' => {
|
||||
i += 1;
|
||||
if (sel_len > i and sel[i] == '\n') i += 1;
|
||||
},
|
||||
' ', '\t', '\n', std.ascii.control_code.ff => i += 1,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
p.i = i;
|
||||
if (v == 0) {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
var buf: [4]u8 = undefined;
|
||||
const ln = std.unicode.utf8Encode(v, &buf) catch return ParseError.InvalidUnicode;
|
||||
const ln = std.unicode.utf8Encode(v, &buf) catch {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
};
|
||||
w.writeAll(buf[0..ln]) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
@@ -280,7 +310,7 @@ pub const Parser = struct {
|
||||
|
||||
// Return the literal character after the backslash.
|
||||
p.i += 2;
|
||||
w.writeAll(p.s[start .. start + 1]) catch return ParseError.WriteError;
|
||||
w.writeByte(sel[start]) catch return ParseError.WriteError;
|
||||
}
|
||||
|
||||
// parseIDSelector parses a selector that matches by id attribute.
|
||||
@@ -383,20 +413,23 @@ pub const Parser = struct {
|
||||
|
||||
// parseString parses a single- or double-quoted string.
|
||||
fn parseString(p: *Parser, writer: anytype) ParseError!void {
|
||||
var i = p.i;
|
||||
if (p.s.len < i + 2) return ParseError.ExpectedString;
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
const quote = p.s[i];
|
||||
var i = p.i;
|
||||
if (sel_len < i + 2) return ParseError.ExpectedString;
|
||||
|
||||
const quote = sel[i];
|
||||
i += 1;
|
||||
|
||||
loop: while (i < p.s.len) {
|
||||
switch (p.s[i]) {
|
||||
loop: while (i < sel_len) {
|
||||
switch (sel[i]) {
|
||||
'\\' => {
|
||||
if (p.s.len > i + 1) {
|
||||
const c = p.s[i + 1];
|
||||
if (sel_len > i + 1) {
|
||||
const c = sel[i + 1];
|
||||
switch (c) {
|
||||
'\r' => {
|
||||
if (p.s.len > i + 2 and p.s[i + 2] == '\n') {
|
||||
if (sel_len > i + 2 and sel[i + 2] == '\n') {
|
||||
i += 3;
|
||||
continue :loop;
|
||||
}
|
||||
@@ -418,17 +451,17 @@ pub const Parser = struct {
|
||||
else => |c| {
|
||||
if (c == quote) break :loop;
|
||||
const start = i;
|
||||
while (i < p.s.len) {
|
||||
const cc = p.s[i];
|
||||
while (i < sel_len) {
|
||||
const cc = sel[i];
|
||||
if (cc == quote or cc == '\\' or c == '\r' or c == '\n' or c == std.ascii.control_code.ff) break;
|
||||
i += 1;
|
||||
}
|
||||
writer.writeAll(p.s[start..i]) catch return ParseError.WriteError;
|
||||
writer.writeAll(sel[start..i]) catch return ParseError.WriteError;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (i >= p.s.len) return ParseError.InvalidString;
|
||||
if (i >= sel_len) return ParseError.InvalidString;
|
||||
|
||||
// Consume the final quote.
|
||||
i += 1;
|
||||
|
||||
291
src/browser/cssom/css_parser.zig
Normal file
291
src/browser/cssom/css_parser.zig
Normal file
@@ -0,0 +1,291 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const CSSConstants = struct {
|
||||
const IMPORTANT = "!important";
|
||||
const URL_PREFIX = "url(";
|
||||
};
|
||||
|
||||
pub const CSSParserState = enum {
|
||||
seek_name,
|
||||
in_name,
|
||||
seek_colon,
|
||||
seek_value,
|
||||
in_value,
|
||||
in_quoted_value,
|
||||
in_single_quoted_value,
|
||||
in_url,
|
||||
in_important,
|
||||
};
|
||||
|
||||
pub const CSSDeclaration = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
is_important: bool,
|
||||
};
|
||||
|
||||
pub const CSSParser = struct {
|
||||
state: CSSParserState,
|
||||
name_start: usize,
|
||||
name_end: usize,
|
||||
value_start: usize,
|
||||
position: usize,
|
||||
paren_depth: usize,
|
||||
escape_next: bool,
|
||||
|
||||
pub fn init() CSSParser {
|
||||
return .{
|
||||
.state = .seek_name,
|
||||
.name_start = 0,
|
||||
.name_end = 0,
|
||||
.value_start = 0,
|
||||
.position = 0,
|
||||
.paren_depth = 0,
|
||||
.escape_next = false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration {
|
||||
var parser = init();
|
||||
var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty;
|
||||
|
||||
while (parser.position < text.len) {
|
||||
const c = text[parser.position];
|
||||
|
||||
switch (parser.state) {
|
||||
.seek_name => {
|
||||
if (!std.ascii.isWhitespace(c)) {
|
||||
parser.name_start = parser.position;
|
||||
parser.state = .in_name;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.in_name => {
|
||||
if (c == ':') {
|
||||
parser.name_end = parser.position;
|
||||
parser.state = .seek_value;
|
||||
} else if (std.ascii.isWhitespace(c)) {
|
||||
parser.name_end = parser.position;
|
||||
parser.state = .seek_colon;
|
||||
}
|
||||
},
|
||||
.seek_colon => {
|
||||
if (c == ':') {
|
||||
parser.state = .seek_value;
|
||||
} else if (!std.ascii.isWhitespace(c)) {
|
||||
parser.state = .seek_name;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.seek_value => {
|
||||
if (!std.ascii.isWhitespace(c)) {
|
||||
parser.value_start = parser.position;
|
||||
if (c == '"') {
|
||||
parser.state = .in_quoted_value;
|
||||
} else if (c == '\'') {
|
||||
parser.state = .in_single_quoted_value;
|
||||
} else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) {
|
||||
parser.state = .in_url;
|
||||
parser.paren_depth = 1;
|
||||
parser.position += 3;
|
||||
} else {
|
||||
parser.state = .in_value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
.in_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '(') {
|
||||
parser.paren_depth += 1;
|
||||
} else if (c == ')' and parser.paren_depth > 0) {
|
||||
parser.paren_depth -= 1;
|
||||
} else if (c == ';' and parser.paren_depth == 0) {
|
||||
try parser.finishDeclaration(arena, &declarations, text);
|
||||
parser.state = .seek_name;
|
||||
}
|
||||
},
|
||||
.in_quoted_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '"') {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
},
|
||||
.in_single_quoted_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '\'') {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
},
|
||||
.in_url => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '(') {
|
||||
parser.paren_depth += 1;
|
||||
} else if (c == ')') {
|
||||
parser.paren_depth -= 1;
|
||||
if (parser.paren_depth == 0) {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
}
|
||||
},
|
||||
.in_important => {},
|
||||
}
|
||||
|
||||
parser.position += 1;
|
||||
}
|
||||
|
||||
try parser.finalize(arena, &declarations, text);
|
||||
|
||||
return declarations.items;
|
||||
}
|
||||
|
||||
fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
|
||||
const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace);
|
||||
if (name.len == 0) return;
|
||||
|
||||
const raw_value = text[self.value_start..self.position];
|
||||
const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace);
|
||||
|
||||
var final_value = value;
|
||||
var is_important = false;
|
||||
|
||||
if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) {
|
||||
is_important = true;
|
||||
final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
try declarations.append(arena, .{
|
||||
.name = name,
|
||||
.value = final_value,
|
||||
.is_important = is_important,
|
||||
});
|
||||
}
|
||||
|
||||
fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
|
||||
if (self.state != .in_value) {
|
||||
return;
|
||||
}
|
||||
return self.finishDeclaration(arena, declarations, text);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "CSSParser - Simple property" {
|
||||
defer testing.reset();
|
||||
|
||||
const text = "color: red;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("red", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Property with !important" {
|
||||
defer testing.reset();
|
||||
const text = "margin: 10px !important;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("margin", declarations[0].name);
|
||||
try testing.expectEqual("10px", declarations[0].value);
|
||||
try testing.expectEqual(true, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Multiple properties" {
|
||||
defer testing.reset();
|
||||
const text = "color: red; font-size: 12px; margin: 5px !important;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expect(declarations.len == 3);
|
||||
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("red", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
|
||||
try testing.expectEqual("font-size", declarations[1].name);
|
||||
try testing.expectEqual("12px", declarations[1].value);
|
||||
try testing.expectEqual(false, declarations[1].is_important);
|
||||
|
||||
try testing.expectEqual("margin", declarations[2].name);
|
||||
try testing.expectEqual("5px", declarations[2].value);
|
||||
try testing.expectEqual(true, declarations[2].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Quoted value with semicolon" {
|
||||
defer testing.reset();
|
||||
const text = "content: \"Hello; world!\";";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("content", declarations[0].name);
|
||||
try testing.expectEqual("\"Hello; world!\"", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - URL value" {
|
||||
defer testing.reset();
|
||||
const text = "background-image: url(\"test.png\");";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("background-image", declarations[0].name);
|
||||
try testing.expectEqual("url(\"test.png\")", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Whitespace handling" {
|
||||
defer testing.reset();
|
||||
const text = " color : purple ; margin : 10px ; ";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(2, declarations.len);
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("purple", declarations[0].value);
|
||||
try testing.expectEqual("margin", declarations[1].name);
|
||||
try testing.expectEqual("10px", declarations[1].value);
|
||||
}
|
||||
223
src/browser/cssom/css_style_declaration.zig
Normal file
223
src/browser/cssom/css_style_declaration.zig
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const CSSParser = @import("./css_parser.zig").CSSParser;
|
||||
const CSSValueAnalyzer = @import("./css_value_analyzer.zig").CSSValueAnalyzer;
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
pub const Interfaces = .{
|
||||
CSSStyleDeclaration,
|
||||
CSSRule,
|
||||
};
|
||||
|
||||
const CSSRule = struct {};
|
||||
|
||||
pub const CSSStyleDeclaration = struct {
|
||||
store: std.StringHashMapUnmanaged(Property),
|
||||
order: std.ArrayListUnmanaged([]const u8),
|
||||
|
||||
const Property = struct {
|
||||
value: []const u8,
|
||||
priority: bool,
|
||||
};
|
||||
|
||||
pub fn get_cssFloat(self: *const CSSStyleDeclaration) []const u8 {
|
||||
return self._getPropertyValue("float");
|
||||
}
|
||||
|
||||
pub fn set_cssFloat(self: *CSSStyleDeclaration, value: ?[]const u8, state: *SessionState) !void {
|
||||
const final_value = value orelse "";
|
||||
return self._setProperty("float", final_value, null, state);
|
||||
}
|
||||
|
||||
pub fn get_cssText(self: *const CSSStyleDeclaration, state: *SessionState) ![]const u8 {
|
||||
var buffer: std.ArrayListUnmanaged(u8) = .empty;
|
||||
const writer = buffer.writer(state.call_arena);
|
||||
for (self.order.items) |name| {
|
||||
const prop = self.store.get(name).?;
|
||||
const escaped = try CSSValueAnalyzer.escapeCSSValue(state.call_arena, prop.value);
|
||||
try writer.print("{s}: {s}", .{ name, escaped });
|
||||
if (prop.priority) try writer.writeAll(" !important");
|
||||
try writer.writeAll("; ");
|
||||
}
|
||||
return buffer.items;
|
||||
}
|
||||
|
||||
// TODO Propagate also upward to parent node
|
||||
pub fn set_cssText(self: *CSSStyleDeclaration, text: []const u8, state: *SessionState) !void {
|
||||
self.store.clearRetainingCapacity();
|
||||
self.order.clearRetainingCapacity();
|
||||
|
||||
// call_arena is safe here, because _setProperty will dupe the name
|
||||
// using the state's longer-living arena.
|
||||
const declarations = try CSSParser.parseDeclarations(state.call_arena, text);
|
||||
|
||||
for (declarations) |decl| {
|
||||
if (!CSSValueAnalyzer.isValidPropertyName(decl.name)) continue;
|
||||
const priority: ?[]const u8 = if (decl.is_important) "important" else null;
|
||||
try self._setProperty(decl.name, decl.value, priority, state);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_length(self: *const CSSStyleDeclaration) usize {
|
||||
return self.order.items.len;
|
||||
}
|
||||
|
||||
pub fn get_parentRule() ?CSSRule {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _getPropertyPriority(self: *const CSSStyleDeclaration, name: []const u8) []const u8 {
|
||||
return if (self.store.get(name)) |prop| (if (prop.priority) "important" else "") else "";
|
||||
}
|
||||
|
||||
// TODO should handle properly shorthand properties and canonical forms
|
||||
pub fn _getPropertyValue(self: *const CSSStyleDeclaration, name: []const u8) []const u8 {
|
||||
return if (self.store.get(name)) |prop| prop.value else "";
|
||||
}
|
||||
|
||||
pub fn _item(self: *const CSSStyleDeclaration, index: usize) []const u8 {
|
||||
return if (index < self.order.items.len) self.order.items[index] else "";
|
||||
}
|
||||
|
||||
pub fn _removeProperty(self: *CSSStyleDeclaration, name: []const u8) ![]const u8 {
|
||||
const prop = self.store.fetchRemove(name) orelse return "";
|
||||
for (self.order.items, 0..) |item, i| {
|
||||
if (std.mem.eql(u8, item, name)) {
|
||||
_ = self.order.orderedRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// safe to return, since it's in our state.arena
|
||||
return prop.value.value;
|
||||
}
|
||||
|
||||
pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, state: *SessionState) !void {
|
||||
const owned_value = try state.arena.dupe(u8, value);
|
||||
const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important");
|
||||
|
||||
const gop = try self.store.getOrPut(state.arena, name);
|
||||
if (!gop.found_existing) {
|
||||
const owned_name = try state.arena.dupe(u8, name);
|
||||
gop.key_ptr.* = owned_name;
|
||||
try self.order.append(state.arena, owned_name);
|
||||
}
|
||||
|
||||
gop.value_ptr.* = .{ .value = owned_value, .priority = is_important };
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "CSSOM.CSSStyleDeclaration" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let style = document.getElementById('content').style", "undefined" },
|
||||
.{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" },
|
||||
.{ "style.length", "3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.getPropertyValue('color')", "red" },
|
||||
.{ "style.getPropertyValue('font-size')", "12px" },
|
||||
.{ "style.getPropertyValue('unknown-property')", "" },
|
||||
|
||||
.{ "style.getPropertyPriority('margin')", "important" },
|
||||
.{ "style.getPropertyPriority('color')", "" },
|
||||
.{ "style.getPropertyPriority('unknown-property')", "" },
|
||||
|
||||
.{ "style.item(0)", "color" },
|
||||
.{ "style.item(1)", "font-size" },
|
||||
.{ "style.item(2)", "margin" },
|
||||
.{ "style.item(3)", "" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.setProperty('background-color', 'blue')", "undefined" },
|
||||
.{ "style.getPropertyValue('background-color')", "blue" },
|
||||
.{ "style.length", "4" },
|
||||
|
||||
.{ "style.setProperty('color', 'green')", "undefined" },
|
||||
.{ "style.getPropertyValue('color')", "green" },
|
||||
.{ "style.length", "4" },
|
||||
|
||||
.{ "style.setProperty('padding', '10px', 'important')", "undefined" },
|
||||
.{ "style.getPropertyValue('padding')", "10px" },
|
||||
.{ "style.getPropertyPriority('padding')", "important" },
|
||||
|
||||
.{ "style.setProperty('border', '1px solid black', 'IMPORTANT')", "undefined" },
|
||||
.{ "style.getPropertyPriority('border')", "important" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.removeProperty('color')", "green" },
|
||||
.{ "style.getPropertyValue('color')", "" },
|
||||
.{ "style.length", "5" },
|
||||
|
||||
.{ "style.removeProperty('unknown-property')", "" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.cssText.includes('font-size: 12px;')", "true" },
|
||||
.{ "style.cssText.includes('margin: 5px !important;')", "true" },
|
||||
.{ "style.cssText.includes('padding: 10px !important;')", "true" },
|
||||
.{ "style.cssText.includes('border: 1px solid black !important;')", "true" },
|
||||
|
||||
.{ "style.cssText = 'color: purple; text-align: center;'", "color: purple; text-align: center;" },
|
||||
.{ "style.length", "2" },
|
||||
.{ "style.getPropertyValue('color')", "purple" },
|
||||
.{ "style.getPropertyValue('text-align')", "center" },
|
||||
.{ "style.getPropertyValue('font-size')", "" },
|
||||
|
||||
.{ "style.setProperty('cont', 'Hello; world!')", "undefined" },
|
||||
.{ "style.getPropertyValue('cont')", "Hello; world!" },
|
||||
|
||||
.{ "style.cssText = 'content: \"Hello; world!\"; background-image: url(\"test.png\");'", "content: \"Hello; world!\"; background-image: url(\"test.png\");" },
|
||||
.{ "style.getPropertyValue('content')", "\"Hello; world!\"" },
|
||||
.{ "style.getPropertyValue('background-image')", "url(\"test.png\")" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.cssFloat", "" },
|
||||
.{ "style.cssFloat = 'left'", "left" },
|
||||
.{ "style.cssFloat", "left" },
|
||||
.{ "style.getPropertyValue('float')", "left" },
|
||||
|
||||
.{ "style.cssFloat = 'right'", "right" },
|
||||
.{ "style.cssFloat", "right" },
|
||||
|
||||
.{ "style.cssFloat = null", "null" },
|
||||
.{ "style.cssFloat", "" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.setProperty('display', '')", "undefined" },
|
||||
.{ "style.getPropertyValue('display')", "" },
|
||||
|
||||
.{ "style.cssText = ' color : purple ; margin : 10px ; '", " color : purple ; margin : 10px ; " },
|
||||
.{ "style.getPropertyValue('color')", "purple" },
|
||||
.{ "style.getPropertyValue('margin')", "10px" },
|
||||
|
||||
.{ "style.setProperty('border-bottom-left-radius', '5px')", "undefined" },
|
||||
.{ "style.getPropertyValue('border-bottom-left-radius')", "5px" },
|
||||
}, .{});
|
||||
}
|
||||
811
src/browser/cssom/css_value_analyzer.zig
Normal file
811
src/browser/cssom/css_value_analyzer.zig
Normal file
@@ -0,0 +1,811 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const CSSValueAnalyzer = struct {
|
||||
pub fn isNumericWithUnit(value: []const u8) bool {
|
||||
if (value.len == 0) return false;
|
||||
|
||||
if (!std.ascii.isDigit(value[0]) and
|
||||
value[0] != '+' and value[0] != '-' and value[0] != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var i: usize = 0;
|
||||
var has_digit = false;
|
||||
var decimal_point = false;
|
||||
|
||||
while (i < value.len) : (i += 1) {
|
||||
const c = value[i];
|
||||
if (std.ascii.isDigit(c)) {
|
||||
has_digit = true;
|
||||
} else if (c == '.' and !decimal_point) {
|
||||
decimal_point = true;
|
||||
} else if ((c == 'e' or c == 'E') and has_digit) {
|
||||
if (i + 1 >= value.len) return false;
|
||||
if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break;
|
||||
i += 1;
|
||||
if (value[i] == '+' or value[i] == '-') {
|
||||
i += 1;
|
||||
}
|
||||
var has_exp_digits = false;
|
||||
while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) {
|
||||
has_exp_digits = true;
|
||||
}
|
||||
if (!has_exp_digits) return false;
|
||||
break;
|
||||
} else if (c != '-' and c != '+') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!has_digit) return false;
|
||||
|
||||
if (i == value.len) return true;
|
||||
|
||||
const unit = value[i..];
|
||||
return CSSKeywords.isValidUnit(unit);
|
||||
}
|
||||
|
||||
pub fn isHexColor(value: []const u8) bool {
|
||||
if (!std.mem.startsWith(u8, value, "#")) return false;
|
||||
|
||||
const hex_part = value[1..];
|
||||
if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) return false;
|
||||
|
||||
for (hex_part) |c| {
|
||||
if (!std.ascii.isHex(c)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn isMultiValueProperty(value: []const u8) bool {
|
||||
var parts = std.mem.splitAny(u8, value, " ");
|
||||
var multi_value_parts: usize = 0;
|
||||
var all_parts_valid = true;
|
||||
|
||||
while (parts.next()) |part| {
|
||||
if (part.len == 0) continue;
|
||||
multi_value_parts += 1;
|
||||
|
||||
const is_numeric = isNumericWithUnit(part);
|
||||
const is_hex_color = isHexColor(part);
|
||||
const is_known_keyword = CSSKeywords.isKnownKeyword(part);
|
||||
const is_function = CSSKeywords.startsWithFunction(part);
|
||||
|
||||
if (!is_numeric and !is_hex_color and !is_known_keyword and !is_function) {
|
||||
all_parts_valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return multi_value_parts >= 2 and all_parts_valid;
|
||||
}
|
||||
|
||||
pub fn isAlreadyQuoted(value: []const u8) bool {
|
||||
return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or
|
||||
(value[0] == '\'' and value[value.len - 1] == '\''));
|
||||
}
|
||||
|
||||
pub fn isValidPropertyName(name: []const u8) bool {
|
||||
if (name.len == 0) return false;
|
||||
|
||||
if (std.mem.startsWith(u8, name, "--")) {
|
||||
if (name.len == 2) return false;
|
||||
for (name[2..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const first_char = name[0];
|
||||
if (!std.ascii.isAlphabetic(first_char) and first_char != '-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (first_char == '-') {
|
||||
if (name.len < 2) return false;
|
||||
|
||||
if (!std.ascii.isAlphabetic(name[1])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (name[2..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name[1..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } {
|
||||
const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace);
|
||||
|
||||
if (std.mem.endsWith(u8, trimmed, "!important")) {
|
||||
const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace);
|
||||
return .{ .value = clean_value, .is_important = true };
|
||||
}
|
||||
|
||||
return .{ .value = trimmed, .is_important = false };
|
||||
}
|
||||
|
||||
pub fn needsQuotes(value: []const u8) bool {
|
||||
if (value.len == 0) return true;
|
||||
if (isAlreadyQuoted(value)) return false;
|
||||
|
||||
if (CSSKeywords.containsSpecialChar(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.indexOfScalar(u8, value, ' ') == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const is_url = std.mem.startsWith(u8, value, "url(");
|
||||
const is_function = CSSKeywords.startsWithFunction(value);
|
||||
|
||||
return !isMultiValueProperty(value) and
|
||||
!is_url and
|
||||
!is_function;
|
||||
}
|
||||
|
||||
pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 {
|
||||
if (!needsQuotes(value)) {
|
||||
return value;
|
||||
}
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
|
||||
// We'll need at least this much space, +2 for the quotes
|
||||
try out.ensureTotalCapacity(arena, value.len + 2);
|
||||
const writer = out.writer(arena);
|
||||
|
||||
try writer.writeByte('"');
|
||||
|
||||
for (value, 0..) |c, i| {
|
||||
switch (c) {
|
||||
'"' => try writer.writeAll("\\\""),
|
||||
'\\' => try writer.writeAll("\\\\"),
|
||||
'\n' => try writer.writeAll("\\A "),
|
||||
'\r' => try writer.writeAll("\\D "),
|
||||
'\t' => try writer.writeAll("\\9 "),
|
||||
0...8, 11, 12, 14...31, 127 => {
|
||||
try writer.print("\\{x}", .{c});
|
||||
if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
},
|
||||
else => try writer.writeByte(c),
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeByte('"');
|
||||
return out.items;
|
||||
}
|
||||
|
||||
pub fn isKnownKeyword(value: []const u8) bool {
|
||||
return CSSKeywords.isKnownKeyword(value);
|
||||
}
|
||||
|
||||
pub fn containsSpecialChar(value: []const u8) bool {
|
||||
return CSSKeywords.containsSpecialChar(value);
|
||||
}
|
||||
};
|
||||
|
||||
const CSSKeywords = struct {
|
||||
const border_styles = [_][]const u8{
|
||||
"none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset",
|
||||
};
|
||||
|
||||
const color_names = [_][]const u8{
|
||||
"black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent",
|
||||
"currentColor", "inherit",
|
||||
};
|
||||
|
||||
const position_keywords = [_][]const u8{
|
||||
"auto", "center", "left", "right", "top", "bottom",
|
||||
};
|
||||
|
||||
const background_repeat = [_][]const u8{
|
||||
"repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round",
|
||||
};
|
||||
|
||||
const font_styles = [_][]const u8{
|
||||
"normal", "italic", "oblique", "bold", "bolder", "lighter",
|
||||
};
|
||||
|
||||
const font_sizes = [_][]const u8{
|
||||
"xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large",
|
||||
"smaller", "larger",
|
||||
};
|
||||
|
||||
const font_families = [_][]const u8{
|
||||
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
|
||||
};
|
||||
|
||||
const css_global = [_][]const u8{
|
||||
"initial", "inherit", "unset", "revert",
|
||||
};
|
||||
|
||||
const display_values = [_][]const u8{
|
||||
"block", "inline", "inline-block", "flex", "grid", "none",
|
||||
};
|
||||
|
||||
const length_units = [_][]const u8{
|
||||
"px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm",
|
||||
"ex", "ch", "fr",
|
||||
};
|
||||
|
||||
const angle_units = [_][]const u8{
|
||||
"deg", "rad", "grad", "turn",
|
||||
};
|
||||
|
||||
const time_units = [_][]const u8{
|
||||
"s", "ms",
|
||||
};
|
||||
|
||||
const frequency_units = [_][]const u8{
|
||||
"Hz", "kHz",
|
||||
};
|
||||
|
||||
const resolution_units = [_][]const u8{
|
||||
"dpi", "dpcm", "dppx",
|
||||
};
|
||||
|
||||
const special_chars = [_]u8{
|
||||
'"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F',
|
||||
};
|
||||
|
||||
const functions = [_][]const u8{
|
||||
"rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(",
|
||||
"linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(",
|
||||
};
|
||||
|
||||
pub fn isKnownKeyword(value: []const u8) bool {
|
||||
const all_categories = [_][]const []const u8{
|
||||
&border_styles, &color_names, &position_keywords, &background_repeat,
|
||||
&font_styles, &font_sizes, &font_families, &css_global,
|
||||
&display_values,
|
||||
};
|
||||
|
||||
for (all_categories) |category| {
|
||||
for (category) |keyword| {
|
||||
if (std.ascii.eqlIgnoreCase(value, keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn containsSpecialChar(value: []const u8) bool {
|
||||
for (value) |c| {
|
||||
for (special_chars) |special| {
|
||||
if (c == special) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isValidUnit(unit: []const u8) bool {
|
||||
const all_units = [_][]const []const u8{
|
||||
&length_units, &angle_units, &time_units, &frequency_units, &resolution_units,
|
||||
};
|
||||
|
||||
for (all_units) |category| {
|
||||
for (category) |valid_unit| {
|
||||
if (std.ascii.eqlIgnoreCase(unit, valid_unit)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn startsWithFunction(value: []const u8) bool {
|
||||
const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false;
|
||||
if (pos == 0) return false;
|
||||
|
||||
if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) {
|
||||
return false;
|
||||
}
|
||||
const function_name = value[0..pos];
|
||||
return isValidFunctionName(function_name);
|
||||
}
|
||||
|
||||
fn isValidFunctionName(name: []const u8) bool {
|
||||
if (name.len == 0) return false;
|
||||
|
||||
const first = name[0];
|
||||
if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (name[1..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - valid numbers with units" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - scientific notation" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - edge cases and invalid inputs" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("--px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(".px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1epx"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1.2.3px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10xyz"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("5invalid"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - valid hex colors" {
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#000"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#fff"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#123456"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#abcdef"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#ABCDEF"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#12345678"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - invalid hex colors" {
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#00"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#00000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#000000000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#gggggg"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - valid multi-value properties" {
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - invalid multi-value properties" {
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" "));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isAlreadyQuoted - various quoting scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'"));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("''"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello'"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - valid property names" {
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("font-size"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("margin-top"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("z-index"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - invalid property names" {
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color space"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("@color"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color.test"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - with and without !important" {
|
||||
var result = CSSValueAnalyzer.extractImportant("red !important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("blue");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("blue", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant(" green !important ");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("green", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("!important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("important", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: needsQuotes - various scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("hello world"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("test;"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("a{b}"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("test\"quote"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("\"already quoted\""));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("'already quoted'"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(image.png)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("10px 20px"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("simple"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: escapeCSSValue - escaping various characters" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple");
|
||||
try testing.expectEqual("simple", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "\"already quoted\"");
|
||||
try testing.expectEqual("\"already quoted\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote");
|
||||
try testing.expectEqual("\"test\\\"quote\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\nline");
|
||||
try testing.expectEqual("\"test\\A line\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\\back");
|
||||
try testing.expectEqual("\"test\\\\back\"", result);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.isKnownKeyword - case sensitivity" {
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("red"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("solid"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("center"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("inherit"));
|
||||
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("RED"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("Red"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("SOLID"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("Center"));
|
||||
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword("invalid"));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword("unknown"));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.containsSpecialChar - various special characters" {
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test\"quote"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test'quote"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test;end"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test{brace"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test}brace"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test\\back"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test<angle"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test>angle"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test/slash"));
|
||||
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar("normal-text"));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar("text123"));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.isValidUnit - various units" {
|
||||
try testing.expect(CSSKeywords.isValidUnit("px"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("em"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("rem"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("%"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("deg"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("rad"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("s"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("ms"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("PX"));
|
||||
|
||||
try testing.expect(!CSSKeywords.isValidUnit("invalid"));
|
||||
try testing.expect(!CSSKeywords.isValidUnit(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.startsWithFunction - function detection" {
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("url(image.png)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("unknown(test)"));
|
||||
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("not-a-function"));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)"));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("missing-close("));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction(""));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("rgb"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - whitespace handling" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px "));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px "));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - whitespace edge cases" {
|
||||
var result = CSSValueAnalyzer.extractImportant(" ");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("\t\n\r !important\t\n");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red\t!important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - mixed case handling" {
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#AbC"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: edge case - very long inputs" {
|
||||
const long_valid = "a" ** 1000 ++ "px";
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric
|
||||
|
||||
const long_property = "a-" ** 100 ++ "property";
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName(long_property));
|
||||
|
||||
const long_hex = "#" ++ "a" ** 20;
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: boundary conditions - numeric parsing" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("999999999px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1.7976931348623157e+308px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.000000001px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - malformed important declarations" {
|
||||
var result = CSSValueAnalyzer.extractImportant("red ! important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red ! important", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !Important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red !Important", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !IMPORTANT");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red !IMPORTANT", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("!importantred");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("!importantred", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !important !important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red !important", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - complex spacing scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty(" 10px 20px "));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\t20px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\n20px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isAlreadyQuoted - edge cases with quotes" {
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\\\"world\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'hello\\'world'"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello'"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"a\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: needsQuotes - function and URL edge cases" {
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(path with spaces.jpg)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("linear-gradient(to right, red, blue)"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: escapeCSSValue - control characters and Unicode" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab");
|
||||
try testing.expectEqual("\"test\\9 tab\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\rreturn");
|
||||
try testing.expectEqual("\"test\\D return\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x00null");
|
||||
try testing.expectEqual("\"test\\0null\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x7Fdel");
|
||||
try testing.expectEqual("\"test\\7f del\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back");
|
||||
try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - CSS custom properties and vendor prefixes" {
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-moz-border-radius"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-ms-filter"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-o-transition"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-123invalid"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("--"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: startsWithFunction - case sensitivity and partial matches" {
|
||||
try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rg(something)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("ur(something)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)"));
|
||||
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("123function(test)"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - Unicode and invalid characters" {
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#АВС"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#1234567g"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: complex integration scenarios" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
|
||||
|
||||
const result = try CSSValueAnalyzer.escapeCSSValue(allocator, "fake(function with spaces");
|
||||
try testing.expectEqual("\"fake(function with spaces\"", result);
|
||||
|
||||
const important_result = CSSValueAnalyzer.extractImportant("rgb(255,0,0) !important");
|
||||
try testing.expect(important_result.is_important);
|
||||
try testing.expectEqual("rgb(255,0,0)", important_result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: performance edge cases - empty and minimal inputs" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword(""));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar(""));
|
||||
try testing.expect(!CSSKeywords.isValidUnit(""));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction(""));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("a"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("a"));
|
||||
}
|
||||
@@ -112,6 +112,16 @@ pub const CharacterData = struct {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn _before(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.characterDataToNode(self);
|
||||
return Node.before(ref_node, nodes);
|
||||
}
|
||||
|
||||
pub fn _after(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.characterDataToNode(self);
|
||||
return Node.after(ref_node, nodes);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
|
||||
@@ -30,7 +30,7 @@ pub const Comment = struct {
|
||||
|
||||
pub fn constructor(data: ?[]const u8, state: *const SessionState) !*parser.Comment {
|
||||
return parser.documentCreateComment(
|
||||
parser.documentHTMLToDocument(state.document.?),
|
||||
parser.documentHTMLToDocument(state.window.document),
|
||||
data orelse "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ const css = @import("css.zig");
|
||||
|
||||
const Element = @import("element.zig").Element;
|
||||
const ElementUnion = @import("element.zig").Union;
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
const DOMImplementation = @import("implementation.zig").DOMImplementation;
|
||||
|
||||
@@ -41,12 +44,12 @@ pub const Document = struct {
|
||||
|
||||
pub fn constructor(state: *const SessionState) !*parser.DocumentHTML {
|
||||
const doc = try parser.documentCreateDocument(
|
||||
try parser.documentHTMLGetTitle(state.document.?),
|
||||
try parser.documentHTMLGetTitle(state.window.document),
|
||||
);
|
||||
|
||||
// we have to work w/ document instead of html document.
|
||||
const ddoc = parser.documentHTMLToDocument(doc);
|
||||
const ccur = parser.documentHTMLToDocument(state.document.?);
|
||||
const ccur = parser.documentHTMLToDocument(state.window.document);
|
||||
try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
|
||||
try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
|
||||
|
||||
@@ -227,28 +230,21 @@ pub const Document = struct {
|
||||
return css.querySelectorAll(allocator, parser.documentToNode(self), selector);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _prepend(self: *parser.Document, nodes: []const *parser.Node) !void {
|
||||
pub fn _prepend(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.prepend(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _append(self: *parser.Document, nodes: []const *parser.Node) !void {
|
||||
pub fn _append(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.append(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _replaceChildren(self: *parser.Document, nodes: []const *parser.Node) !void {
|
||||
pub fn _replaceChildren(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.replaceChildren(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn deinit(_: *parser.Document, _: std.mem.Allocator) void {}
|
||||
pub fn _createTreeWalker(_: *parser.Document, root: *parser.Node, what_to_show: ?u32, filter: ?TreeWalker.TreeWalkerOpts) !TreeWalker {
|
||||
return try TreeWalker.init(root, what_to_show, filter);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
@@ -31,7 +29,7 @@ pub const DocumentFragment = struct {
|
||||
|
||||
pub fn constructor(state: *const SessionState) !*parser.DocumentFragment {
|
||||
return parser.documentCreateDocumentFragment(
|
||||
parser.documentHTMLToDocument(state.document.?),
|
||||
parser.documentHTMLToDocument(state.window.document),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +41,18 @@ pub const DocumentFragment = struct {
|
||||
_ = self;
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn _prepend(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.prepend(parser.documentFragmentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _append(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.append(parser.documentFragmentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _replaceChildren(self: *parser.DocumentFragment, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.replaceChildren(parser.documentFragmentToNode(self), nodes);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
@@ -25,6 +25,9 @@ const NodeList = @import("nodelist.zig");
|
||||
const Node = @import("node.zig");
|
||||
const MutationObserver = @import("mutation_observer.zig");
|
||||
const IntersectionObserver = @import("intersection_observer.zig");
|
||||
const DOMParser = @import("dom_parser.zig").DOMParser;
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
const NodeFilter = @import("node_filter.zig").NodeFilter;
|
||||
|
||||
pub const Interfaces = .{
|
||||
DOMException,
|
||||
@@ -37,4 +40,7 @@ pub const Interfaces = .{
|
||||
Node.Interfaces,
|
||||
MutationObserver.Interfaces,
|
||||
IntersectionObserver.Interfaces,
|
||||
DOMParser,
|
||||
TreeWalker,
|
||||
NodeFilter,
|
||||
};
|
||||
|
||||
47
src/browser/dom/dom_parser.zig
Normal file
47
src/browser/dom/dom_parser.zig
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DOMParser
|
||||
pub const DOMParser = struct {
|
||||
pub fn constructor() !DOMParser {
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _parseFromString(_: *DOMParser, string: []const u8, mime_type: []const u8) !*parser.DocumentHTML {
|
||||
if (!std.mem.eql(u8, mime_type, "text/html")) {
|
||||
// TODO: Support XML
|
||||
return error.TypeError;
|
||||
}
|
||||
|
||||
return try parser.documentHTMLParseFromStr(string);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.DOMParser" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const dp = new DOMParser()", "undefined" },
|
||||
.{ "dp.parseFromString('<div>abc</div>', 'text/html')", "[object HTMLDocument]" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -31,6 +31,8 @@ const NodeList = @import("nodelist.zig").NodeList;
|
||||
const HTMLElem = @import("../html/elements.zig");
|
||||
pub const Union = @import("../html/elements.zig").Union;
|
||||
|
||||
const log = std.log.scoped(.element);
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#element
|
||||
pub const Element = struct {
|
||||
pub const Self = parser.Element;
|
||||
@@ -135,6 +137,26 @@ pub const Element = struct {
|
||||
}
|
||||
}
|
||||
|
||||
// The closest() method of the Element interface traverses the element and its parents (heading toward the document root) until it finds a node that matches the specified CSS selector.
|
||||
// Returns the closest ancestor Element or itself, which matches the selectors. If there are no such element, null.
|
||||
pub fn _closest(self: *parser.Element, selector: []const u8, state: *SessionState) !?*parser.Element {
|
||||
const cssParse = @import("../css/css.zig").parse;
|
||||
const CssNodeWrap = @import("../css/libdom.zig").Node;
|
||||
const select = try cssParse(state.call_arena, selector, .{});
|
||||
|
||||
var current: CssNodeWrap = .{ .node = parser.elementToNode(self) };
|
||||
while (true) {
|
||||
if (try select.match(current)) {
|
||||
if (!current.isElement()) {
|
||||
log.err("closest: is not an element: {s}", .{try current.tag()});
|
||||
return null;
|
||||
}
|
||||
return parser.nodeToElement(current.node);
|
||||
}
|
||||
current = try current.parent() orelse return null;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _hasAttributes(self: *parser.Element) !bool {
|
||||
return try parser.nodeHasAttributes(parser.elementToNode(self));
|
||||
}
|
||||
@@ -167,8 +189,13 @@ pub const Element = struct {
|
||||
return try parser.elementHasAttribute(self, qname);
|
||||
}
|
||||
|
||||
pub fn _hasAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !bool {
|
||||
return try parser.elementHasAttributeNS(self, ns, qname);
|
||||
}
|
||||
|
||||
// https://dom.spec.whatwg.org/#dom-element-toggleattribute
|
||||
pub fn _toggleAttribute(self: *parser.Element, qname: []const u8, force: ?bool) !bool {
|
||||
pub fn _toggleAttribute(self: *parser.Element, qname: []u8, force: ?bool) !bool {
|
||||
_ = std.ascii.lowerString(qname, qname);
|
||||
const exists = try parser.elementHasAttribute(self, qname);
|
||||
|
||||
// If attribute is null, then:
|
||||
@@ -181,6 +208,9 @@ pub const Element = struct {
|
||||
try parser.elementSetAttribute(self, qname, "");
|
||||
return true;
|
||||
}
|
||||
if (try parser.validateName(qname) == false) {
|
||||
return parser.DOMError.InvalidCharacter;
|
||||
}
|
||||
|
||||
// Return false.
|
||||
return false;
|
||||
@@ -313,35 +343,50 @@ pub const Element = struct {
|
||||
return css.querySelectorAll(state.arena, parser.elementToNode(self), selector);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _prepend(self: *parser.Element, nodes: []const *parser.Node) !void {
|
||||
pub fn _prepend(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.prepend(parser.elementToNode(self), nodes);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _append(self: *parser.Element, nodes: []const *parser.Node) !void {
|
||||
pub fn _append(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.append(parser.elementToNode(self), nodes);
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn _replaceChildren(self: *parser.Element, nodes: []const *parser.Node) !void {
|
||||
pub fn _before(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.elementToNode(self);
|
||||
return Node.before(ref_node, nodes);
|
||||
}
|
||||
|
||||
pub fn _after(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.elementToNode(self);
|
||||
return Node.after(ref_node, nodes);
|
||||
}
|
||||
|
||||
pub fn _replaceChildren(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.replaceChildren(parser.elementToNode(self), nodes);
|
||||
}
|
||||
|
||||
// A DOMRect object providing information about the size of an element and its position relative to the viewport.
|
||||
// Returns a 0 DOMRect object if the element is eventually detached from the main window
|
||||
pub fn _getBoundingClientRect(self: *parser.Element, state: *SessionState) !DOMRect {
|
||||
// Since we are lazy rendering we need to do this check. We could store the renderer in a viewport such that it could cache these, but it would require tracking changes.
|
||||
const root = try parser.nodeGetRootNode(parser.elementToNode(self));
|
||||
if (root != parser.documentToNode(parser.documentHTMLToDocument(state.window.document))) {
|
||||
return DOMRect{ .x = 0, .y = 0, .width = 0, .height = 0 };
|
||||
}
|
||||
return state.renderer.getRect(self);
|
||||
}
|
||||
|
||||
// returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
|
||||
// We do not render so just always return the element's rect.
|
||||
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![1]DOMRect {
|
||||
return [_]DOMRect{try state.renderer.getRect(self)};
|
||||
// Returns a collection of DOMRect objects that indicate the bounding rectangles for each CSS border box in a client.
|
||||
// We do not render so it only always return the element's bounding rect.
|
||||
// Returns an empty array if the element is eventually detached from the main window
|
||||
pub fn _getClientRects(self: *parser.Element, state: *SessionState) ![]DOMRect {
|
||||
const root = try parser.nodeGetRootNode(parser.elementToNode(self));
|
||||
if (root != parser.documentToNode(parser.documentHTMLToDocument(state.window.document))) {
|
||||
return &.{};
|
||||
}
|
||||
const heap_ptr = try state.call_arena.create(DOMRect);
|
||||
heap_ptr.* = try state.renderer.getRect(self);
|
||||
return heap_ptr[0..1];
|
||||
}
|
||||
|
||||
pub fn get_clientWidth(_: *parser.Element, state: *SessionState) u32 {
|
||||
@@ -358,6 +403,10 @@ pub const Element = struct {
|
||||
const s = try cssParse(state.call_arena, selectors, .{});
|
||||
return s.match(CssNodeWrap{ .node = parser.elementToNode(self) });
|
||||
}
|
||||
|
||||
pub fn _scrollIntoViewIfNeeded(_: *parser.Element, center_if_needed: ?bool) void {
|
||||
_ = center_if_needed;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
@@ -392,6 +441,20 @@ test "Browser.DOM.Element" {
|
||||
.{ "cl.length", "2" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const el2 = document.createElement('div');", "undefined" },
|
||||
.{ "el2.id = 'closest'; el2.className = 'ok';", "ok" },
|
||||
.{ "el2.closest('#closest')", "[object HTMLDivElement]" },
|
||||
.{ "el2.closest('.ok')", "[object HTMLDivElement]" },
|
||||
.{ "el2.closest('#9000')", "null" },
|
||||
.{ "el2.closest('.notok')", "null" },
|
||||
|
||||
.{ "const sp = document.createElement('span');", "undefined" },
|
||||
.{ "el2.appendChild(sp);", "[object HTMLSpanElement]" },
|
||||
.{ "sp.closest('#closest')", "[object HTMLDivElement]" },
|
||||
.{ "sp.closest('#9000')", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let a = document.getElementById('content')", "undefined" },
|
||||
.{ "a.hasAttributes()", "true" },
|
||||
@@ -500,25 +563,45 @@ test "Browser.DOM.Element" {
|
||||
.{ "document.getElementById('para').clientHeight", "1" },
|
||||
|
||||
.{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
|
||||
.{ "r1.x", "1" },
|
||||
.{ "r1.x", "0" },
|
||||
.{ "r1.y", "0" },
|
||||
.{ "r1.width", "1" },
|
||||
.{ "r1.height", "1" },
|
||||
|
||||
.{ "let r2 = document.getElementById('content').getBoundingClientRect()", "undefined" },
|
||||
.{ "r2.x", "2" },
|
||||
.{ "r2.x", "1" },
|
||||
.{ "r2.y", "0" },
|
||||
.{ "r2.width", "1" },
|
||||
.{ "r2.height", "1" },
|
||||
|
||||
.{ "let r3 = document.getElementById('para').getBoundingClientRect()", "undefined" },
|
||||
.{ "r3.x", "1" },
|
||||
.{ "r3.x", "0" },
|
||||
.{ "r3.y", "0" },
|
||||
.{ "r3.width", "1" },
|
||||
.{ "r3.height", "1" },
|
||||
|
||||
.{ "document.getElementById('para').clientWidth", "3" },
|
||||
.{ "document.getElementById('para').clientWidth", "2" },
|
||||
.{ "document.getElementById('para').clientHeight", "1" },
|
||||
|
||||
.{ "let r4 = document.createElement('div').getBoundingClientRect()", null },
|
||||
.{ "r4.x", "0" },
|
||||
.{ "r4.y", "0" },
|
||||
.{ "r4.width", "0" },
|
||||
.{ "r4.height", "0" },
|
||||
|
||||
// Test setup causes WrongDocument or HierarchyRequest error unlike in chrome/firefox
|
||||
// .{ // An element of another document, even if created from the main document, is not rendered.
|
||||
// \\ let div5 = document.createElement('div');
|
||||
// \\ const newDoc = document.implementation.createHTMLDocument("New Document");
|
||||
// \\ newDoc.body.appendChild(div5);
|
||||
// \\ let r5 = div5.getBoundingClientRect();
|
||||
// ,
|
||||
// null,
|
||||
// },
|
||||
// .{ "r5.x", "0" },
|
||||
// .{ "r5.y", "0" },
|
||||
// .{ "r5.width", "0" },
|
||||
// .{ "r5.height", "0" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
@@ -529,4 +612,34 @@ test "Browser.DOM.Element" {
|
||||
.{ "el.matches('#9000')", "false" },
|
||||
.{ "el.matches('.notok')", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const el3 = document.createElement('div');", "undefined" },
|
||||
.{ "el3.scrollIntoViewIfNeeded();", "undefined" },
|
||||
.{ "el3.scrollIntoViewIfNeeded(false);", "undefined" },
|
||||
}, .{});
|
||||
|
||||
// before
|
||||
try runner.testCases(&.{
|
||||
.{ "const before_container = document.createElement('div');", "undefined" },
|
||||
.{ "document.append(before_container);", "undefined" },
|
||||
.{ "const b1 = document.createElement('div');", "undefined" },
|
||||
.{ "before_container.append(b1);", "undefined" },
|
||||
|
||||
.{ "const b1_a = document.createElement('p');", "undefined" },
|
||||
.{ "b1.before(b1_a, 'over 9000');", "undefined" },
|
||||
.{ "before_container.innerHTML", "<p></p>over 9000<div></div>" },
|
||||
}, .{});
|
||||
|
||||
// after
|
||||
try runner.testCases(&.{
|
||||
.{ "const after_container = document.createElement('div');", "undefined" },
|
||||
.{ "document.append(after_container);", "undefined" },
|
||||
.{ "const a1 = document.createElement('div');", "undefined" },
|
||||
.{ "after_container.append(a1);", "undefined" },
|
||||
|
||||
.{ "const a1_a = document.createElement('p');", "undefined" },
|
||||
.{ "a1.after('over 9000', a1_a);", "undefined" },
|
||||
.{ "after_container.innerHTML", "<div></div>over 9000<p></p>" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -42,20 +42,49 @@ pub const EventTarget = struct {
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
const AddEventListenerOpts = union(enum) {
|
||||
opts: Opts,
|
||||
capture: bool,
|
||||
|
||||
const Opts = struct {
|
||||
capture: ?bool,
|
||||
once: ?bool, // currently does nothing
|
||||
passive: ?bool, // currently does nothing
|
||||
signal: ?bool, // currently does nothing
|
||||
};
|
||||
};
|
||||
|
||||
pub fn _addEventListener(
|
||||
self: *parser.EventTarget,
|
||||
typ: []const u8,
|
||||
cbk: Env.Callback,
|
||||
capture: ?bool,
|
||||
cbk: Env.Function,
|
||||
opts_: ?AddEventListenerOpts,
|
||||
state: *SessionState,
|
||||
// TODO: hanle EventListenerOptions
|
||||
// see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
) !void {
|
||||
var capture = false;
|
||||
if (opts_) |opts| {
|
||||
switch (opts) {
|
||||
.capture => |c| capture = c,
|
||||
.opts => |o| {
|
||||
// Done this way so that, for common cases that _only_ set
|
||||
// capture, i.e. {captrue: true}, it works.
|
||||
// But for any case that sets any of the other flags, we
|
||||
// error. If we don't error, this function call would succeed
|
||||
// but the behavior might be wrong. At this point, it's
|
||||
// better to be explicit and error.
|
||||
if (o.once orelse false) return error.NotImplemented;
|
||||
if (o.signal orelse false) return error.NotImplemented;
|
||||
if (o.passive orelse false) return error.NotImplemented;
|
||||
capture = o.capture orelse false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// check if event target has already this listener
|
||||
const lst = try parser.eventTargetHasListener(
|
||||
self,
|
||||
typ,
|
||||
capture orelse false,
|
||||
capture,
|
||||
cbk.id,
|
||||
);
|
||||
if (lst != null) {
|
||||
@@ -68,14 +97,14 @@ pub const EventTarget = struct {
|
||||
self,
|
||||
typ,
|
||||
&eh.node,
|
||||
capture orelse false,
|
||||
capture,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn _removeEventListener(
|
||||
self: *parser.EventTarget,
|
||||
typ: []const u8,
|
||||
cbk: Env.Callback,
|
||||
cbk: Env.Function,
|
||||
capture: ?bool,
|
||||
// TODO: hanle EventListenerOptions
|
||||
// see #https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
@@ -172,7 +201,7 @@ test "Browser.DOM.EventTarget" {
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{ "content.removeEventListener('basic', cbk, true)", "undefined" },
|
||||
.{ "content.removeEventListener('basic', cbk, {capture: true})", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "0" },
|
||||
}, .{});
|
||||
|
||||
@@ -149,17 +149,43 @@ pub fn HTMLCollectionByName(
|
||||
};
|
||||
}
|
||||
|
||||
pub fn HTMLCollectionAll(
|
||||
root: ?*parser.Node,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchTrue = .{} },
|
||||
.include_root = include_root,
|
||||
// HTMLAllCollection is a special type: instances of it are falsy. It's the only
|
||||
// object in the WebAPI that behaves like this - in fact, it's even a special
|
||||
// case in the JavaScript spec.
|
||||
// This is important, because a lot of browser detection rely on this behavior
|
||||
// to determine what browser is running.
|
||||
|
||||
// It's also possible to use an instance like a function:
|
||||
// document.all(3)
|
||||
// document.all('some_id')
|
||||
pub const HTMLAllCollection = struct {
|
||||
pub const prototype = *HTMLCollection;
|
||||
|
||||
proto: HTMLCollection,
|
||||
|
||||
pub const mark_as_undetectable = true;
|
||||
|
||||
pub fn init(root: ?*parser.Node) HTMLAllCollection {
|
||||
return .{ .proto = .{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchTrue = .{} },
|
||||
.include_root = true,
|
||||
} };
|
||||
}
|
||||
|
||||
const CAllAsFunctionArg = union(enum) {
|
||||
index: u32,
|
||||
id: []const u8,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn jsCallAsFunction(self: *HTMLAllCollection, arg: CAllAsFunctionArg) !?Union {
|
||||
return switch (arg) {
|
||||
.index => |i| self.proto._item(i),
|
||||
.id => |id| self.proto._namedItem(id),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionChildren(
|
||||
root: ?*parser.Node,
|
||||
@@ -406,10 +432,16 @@ pub const HTMLCollection = struct {
|
||||
for (0..len) |i| {
|
||||
const node = try self.item(@intCast(i)) orelse unreachable;
|
||||
const e = @as(*parser.Element, @ptrCast(node));
|
||||
try js_this.setIndex(@intCast(i), e);
|
||||
try js_this.setIndex(@intCast(i), e, .{});
|
||||
|
||||
if (try item_name(e)) |name| {
|
||||
try js_this.set(name, e);
|
||||
// Even though an entry might have an empty id, the spec says
|
||||
// that namedItem("") should always return null
|
||||
if (name.len > 0) {
|
||||
// Named fields should not be enumerable (it is defined with
|
||||
// the LegacyUnenumerableNamedProperties flag.)
|
||||
try js_this.set(name, e, .{ .DONT_ENUM = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,10 @@ test "Browser.DOM.Implementation" {
|
||||
try runner.testCases(&.{
|
||||
.{ "let impl = document.implementation", "undefined" },
|
||||
.{ "impl.createHTMLDocument();", "[object HTMLDocument]" },
|
||||
.{ "impl.createHTMLDocument('foo');", "[object HTMLDocument]" },
|
||||
.{ "const doc = impl.createHTMLDocument('foo');", "undefined" },
|
||||
.{ "doc", "[object HTMLDocument]" },
|
||||
.{ "doc.title", "foo" },
|
||||
.{ "doc.body", "[object HTMLBodyElement]" },
|
||||
.{ "impl.createDocument(null, 'foo');", "[object Document]" },
|
||||
.{ "impl.createDocumentType('foo', 'bar', 'baz')", "[object DocumentType]" },
|
||||
.{ "impl.hasFeature()", "true" },
|
||||
|
||||
@@ -17,14 +17,12 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Element = @import("element.zig").Element;
|
||||
const Document = @import("document.zig").Document;
|
||||
|
||||
pub const Interfaces = .{
|
||||
IntersectionObserver,
|
||||
@@ -42,7 +40,7 @@ const log = std.log.scoped(.events);
|
||||
// The returned Entries are phony, they always indicate full intersection.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
|
||||
pub const IntersectionObserver = struct {
|
||||
callback: Env.Callback,
|
||||
callback: Env.Function,
|
||||
options: IntersectionObserverOptions,
|
||||
state: *SessionState,
|
||||
|
||||
@@ -50,9 +48,9 @@ pub const IntersectionObserver = struct {
|
||||
|
||||
// new IntersectionObserver(callback)
|
||||
// new IntersectionObserver(callback, options) [not supported yet]
|
||||
pub fn constructor(callback: Env.Callback, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver {
|
||||
pub fn constructor(callback: Env.Function, options_: ?IntersectionObserverOptions, state: *SessionState) !IntersectionObserver {
|
||||
var options = IntersectionObserverOptions{
|
||||
.root = parser.documentToNode(parser.documentHTMLToDocument(state.document.?)),
|
||||
.root = parser.documentToNode(parser.documentHTMLToDocument(state.window.document)),
|
||||
.rootMargin = "0px 0px 0px 0px",
|
||||
.threshold = &.{0.0},
|
||||
};
|
||||
@@ -87,8 +85,8 @@ pub const IntersectionObserver = struct {
|
||||
.options = &self.options,
|
||||
});
|
||||
|
||||
var result: Env.Callback.Result = undefined;
|
||||
self.callback.tryCall(.{self.observed_entries.items}, &result) catch {
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch {
|
||||
log.err("intersection observer callback error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
@@ -123,7 +121,7 @@ pub const IntersectionObserverEntry = struct {
|
||||
|
||||
// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
|
||||
pub fn get_boundingClientRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
|
||||
return self.state.renderer.getRect(self.target);
|
||||
return Element._getBoundingClientRect(self.target, self.state);
|
||||
}
|
||||
|
||||
// Returns the ratio of the intersectionRect to the boundingClientRect.
|
||||
@@ -133,7 +131,7 @@ pub const IntersectionObserverEntry = struct {
|
||||
|
||||
// Returns a DOMRectReadOnly representing the target's visible area.
|
||||
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
|
||||
return self.state.renderer.getRect(self.target);
|
||||
return Element._getBoundingClientRect(self.target, self.state);
|
||||
}
|
||||
|
||||
// A Boolean value which is true if the target element intersects with the intersection observer's root. If this is true, then, the IntersectionObserverEntry describes a transition into a state of intersection; if it's false, then you know the transition is from intersecting to not-intersecting.
|
||||
@@ -144,7 +142,7 @@ pub const IntersectionObserverEntry = struct {
|
||||
// Returns a DOMRectReadOnly for the intersection observer's root.
|
||||
pub fn get_rootBounds(self: *const IntersectionObserverEntry) !Element.DOMRect {
|
||||
const root = self.options.root.?;
|
||||
if (@intFromPtr(root) == @intFromPtr(self.state.document.?)) {
|
||||
if (@intFromPtr(root) == @intFromPtr(self.state.window.document)) {
|
||||
return self.state.renderer.boundingRect();
|
||||
}
|
||||
|
||||
@@ -160,7 +158,7 @@ pub const IntersectionObserverEntry = struct {
|
||||
else => return error.InvalidState,
|
||||
}
|
||||
|
||||
return try self.state.renderer.getRect(element);
|
||||
return Element._getBoundingClientRect(element, self.state);
|
||||
}
|
||||
|
||||
// The Element whose intersection with the root changed.
|
||||
@@ -246,24 +244,27 @@ test "Browser.DOM.IntersectionObserver" {
|
||||
// Entry
|
||||
try runner.testCases(&.{
|
||||
.{ "let entry;", "undefined" },
|
||||
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(document.createElement('div'));", "undefined" },
|
||||
.{ "entry.boundingClientRect.x;", "1" },
|
||||
.{ "let div1 = document.createElement('div')", null },
|
||||
.{ "document.body.appendChild(div1);", null },
|
||||
.{ "new IntersectionObserver(entries => { entry = entries[0]; }).observe(div1);", null },
|
||||
.{ "entry.boundingClientRect.x;", "0" },
|
||||
.{ "entry.intersectionRatio;", "1" },
|
||||
.{ "entry.intersectionRect.x;", "1" },
|
||||
.{ "entry.intersectionRect.x;", "0" },
|
||||
.{ "entry.intersectionRect.y;", "0" },
|
||||
.{ "entry.intersectionRect.width;", "1" },
|
||||
.{ "entry.intersectionRect.height;", "1" },
|
||||
.{ "entry.isIntersecting;", "true" },
|
||||
.{ "entry.rootBounds.x;", "0" },
|
||||
.{ "entry.rootBounds.y;", "0" },
|
||||
.{ "entry.rootBounds.width;", "2" },
|
||||
.{ "entry.rootBounds.width;", "1" },
|
||||
.{ "entry.rootBounds.height;", "1" },
|
||||
.{ "entry.target;", "[object HTMLDivElement]" },
|
||||
}, .{});
|
||||
|
||||
// Options
|
||||
try runner.testCases(&.{
|
||||
.{ "const new_root = document.createElement('span');", "undefined" },
|
||||
.{ "const new_root = document.createElement('span');", null },
|
||||
.{ "document.body.appendChild(new_root);", null },
|
||||
.{ "let new_entry;", "undefined" },
|
||||
.{
|
||||
\\ const new_observer = new IntersectionObserver(
|
||||
@@ -273,6 +274,6 @@ test "Browser.DOM.IntersectionObserver" {
|
||||
"undefined",
|
||||
},
|
||||
.{ "new_observer.observe(document.createElement('div'));", "undefined" },
|
||||
.{ "new_entry.rootBounds.x;", "2" },
|
||||
.{ "new_entry.rootBounds.x;", "1" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@ const log = std.log.scoped(.events);
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
|
||||
pub const MutationObserver = struct {
|
||||
cbk: Env.Callback,
|
||||
cbk: Env.Function,
|
||||
arena: Allocator,
|
||||
|
||||
// List of records which were observed. When the scopeEnds, we need to
|
||||
// execute our callback with it.
|
||||
observed: std.ArrayListUnmanaged(*MutationRecord),
|
||||
|
||||
pub fn constructor(cbk: Env.Callback, state: *SessionState) !MutationObserver {
|
||||
pub fn constructor(cbk: Env.Function, state: *SessionState) !MutationObserver {
|
||||
return .{
|
||||
.cbk = cbk,
|
||||
.observed = .{},
|
||||
@@ -103,7 +103,7 @@ pub const MutationObserver = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsCallScopeEnd(self: *MutationObserver, _: anytype) void {
|
||||
pub fn jsCallScopeEnd(self: *MutationObserver) void {
|
||||
const record = self.observed.items;
|
||||
if (record.len == 0) {
|
||||
return;
|
||||
@@ -113,8 +113,8 @@ pub const MutationObserver = struct {
|
||||
|
||||
for (record) |r| {
|
||||
const records = [_]MutationRecord{r.*};
|
||||
var result: Env.Callback.Result = undefined;
|
||||
self.cbk.tryCall(.{records}, &result) catch {
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.cbk.tryCall(void, .{records}, &result) catch {
|
||||
log.err("mutation observer callback error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
|
||||
@@ -33,6 +33,7 @@ const Document = @import("document.zig").Document;
|
||||
const DocumentType = @import("document_type.zig").DocumentType;
|
||||
const DocumentFragment = @import("document_fragment.zig").DocumentFragment;
|
||||
const HTMLCollection = @import("html_collection.zig").HTMLCollection;
|
||||
const HTMLAllCollection = @import("html_collection.zig").HTMLAllCollection;
|
||||
const HTMLCollectionIterator = @import("html_collection.zig").HTMLCollectionIterator;
|
||||
const Walker = @import("walker.zig").WalkerDepthFirst;
|
||||
|
||||
@@ -40,6 +41,8 @@ const Walker = @import("walker.zig").WalkerDepthFirst;
|
||||
const HTML = @import("../html/html.zig");
|
||||
const HTMLElem = @import("../html/elements.zig");
|
||||
|
||||
const log = std.log.scoped(.node);
|
||||
|
||||
// Node interfaces
|
||||
pub const Interfaces = .{
|
||||
Attr,
|
||||
@@ -50,6 +53,7 @@ pub const Interfaces = .{
|
||||
DocumentType,
|
||||
DocumentFragment,
|
||||
HTMLCollection,
|
||||
HTMLAllCollection,
|
||||
HTMLCollectionIterator,
|
||||
HTML.Interfaces,
|
||||
};
|
||||
@@ -260,13 +264,15 @@ pub const Node = struct {
|
||||
return try parser.nodeContains(self, other);
|
||||
}
|
||||
|
||||
pub fn _getRootNode(self: *parser.Node) !?HTMLElem.Union {
|
||||
// TODO return this’s shadow-including root if options["composed"] is true
|
||||
const res = try parser.nodeOwnerDocument(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
|
||||
// Returns itself or ancestor object inheriting from Node.
|
||||
// - An Element inside a standard web page will return an HTMLDocument object representing the entire page (or <iframe>).
|
||||
// - An Element inside a shadow DOM will return the associated ShadowRoot.
|
||||
// - An Element that is not attached to a document or a shadow tree will return the root of the DOM tree it belongs to
|
||||
pub fn _getRootNode(self: *parser.Node, options: ?struct { composed: bool = false }) !Union {
|
||||
if (options) |options_| if (options_.composed) {
|
||||
log.warn("getRootNode composed is not implemented yet", .{});
|
||||
};
|
||||
return try Node.toInterface(try parser.nodeGetRootNode(self));
|
||||
}
|
||||
|
||||
pub fn _hasChildNodes(self: *parser.Node) !bool {
|
||||
@@ -337,65 +343,72 @@ pub const Node = struct {
|
||||
// For now, it checks only if new nodes are not self.
|
||||
// TODO implements the others contraints.
|
||||
// see https://dom.spec.whatwg.org/#concept-node-tree
|
||||
pub fn hierarchy(self: *parser.Node, nodes: []const *parser.Node) !bool {
|
||||
if (nodes.len == 0) return true;
|
||||
|
||||
for (nodes) |node| if (self == node) return false;
|
||||
|
||||
pub fn hierarchy(self: *parser.Node, nodes: []const NodeOrText) bool {
|
||||
for (nodes) |n| {
|
||||
if (n.is(self)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn prepend(self: *parser.Node, nodes: []const *parser.Node) !void {
|
||||
if (nodes.len == 0) return;
|
||||
pub fn prepend(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
|
||||
if (!hierarchy(self, nodes)) {
|
||||
return parser.DOMError.HierarchyRequest;
|
||||
}
|
||||
|
||||
const first = try parser.nodeFirstChild(self);
|
||||
if (first == null) {
|
||||
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
|
||||
|
||||
if (try parser.nodeFirstChild(self)) |first| {
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(self, node);
|
||||
_ = try parser.nodeInsertBefore(self, try node.toNode(doc), first);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeInsertBefore(self, node, first.?);
|
||||
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn append(self: *parser.Node, nodes: []const *parser.Node) !void {
|
||||
if (nodes.len == 0) return;
|
||||
pub fn append(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
|
||||
if (!hierarchy(self, nodes)) {
|
||||
return parser.DOMError.HierarchyRequest;
|
||||
}
|
||||
|
||||
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(self, node);
|
||||
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO according with https://dom.spec.whatwg.org/#parentnode, the
|
||||
// function must accept either node or string.
|
||||
// blocked by https://github.com/lightpanda-io/jsruntime-lib/issues/114
|
||||
pub fn replaceChildren(self: *parser.Node, nodes: []const *parser.Node) !void {
|
||||
if (nodes.len == 0) return;
|
||||
pub fn replaceChildren(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
if (!try hierarchy(self, nodes)) return parser.DOMError.HierarchyRequest;
|
||||
if (!hierarchy(self, nodes)) {
|
||||
return parser.DOMError.HierarchyRequest;
|
||||
}
|
||||
|
||||
// remove existing children
|
||||
try removeChildren(self);
|
||||
|
||||
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
|
||||
// add new children
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(self, node);
|
||||
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,7 +427,87 @@ pub const Node = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(_: *parser.Node, _: std.mem.Allocator) void {}
|
||||
pub fn before(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
const parent = try parser.nodeParentNode(self) orelse return;
|
||||
const doc = (try parser.nodeOwnerDocument(parent)) orelse return;
|
||||
|
||||
var sibling: ?*parser.Node = self;
|
||||
// have to find the first sibling that isn't in nodes
|
||||
CHECK: while (sibling) |s| {
|
||||
for (nodes) |n| {
|
||||
if (n.is(s)) {
|
||||
sibling = try parser.nodePreviousSibling(s);
|
||||
continue :CHECK;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (sibling == null) {
|
||||
sibling = try parser.nodeFirstChild(parent);
|
||||
}
|
||||
|
||||
if (sibling) |ref_node| {
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeInsertBefore(parent, try node.toNode(doc), ref_node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return Node.prepend(self, nodes);
|
||||
}
|
||||
|
||||
pub fn after(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
const parent = try parser.nodeParentNode(self) orelse return;
|
||||
const doc = (try parser.nodeOwnerDocument(parent)) orelse return;
|
||||
|
||||
// have to find the first sibling that isn't in nodes
|
||||
var sibling = try parser.nodeNextSibling(self);
|
||||
CHECK: while (sibling) |s| {
|
||||
for (nodes) |n| {
|
||||
if (n.is(s)) {
|
||||
sibling = try parser.nodeNextSibling(s);
|
||||
continue :CHECK;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (sibling) |ref_node| {
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeInsertBefore(parent, try node.toNode(doc), ref_node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(parent, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
// A lot of functions take either a node or text input.
|
||||
// The text input is to be converted into a Text node.
|
||||
pub const NodeOrText = union(enum) {
|
||||
text: []const u8,
|
||||
node: *parser.Node,
|
||||
|
||||
fn toNode(self: NodeOrText, doc: *parser.Document) !*parser.Node {
|
||||
return switch (self) {
|
||||
.node => |n| n,
|
||||
.text => |txt| @ptrCast(try parser.documentCreateTextNode(doc, txt)),
|
||||
};
|
||||
}
|
||||
|
||||
// Whether the node represented by the NodeOrText is the same as the
|
||||
// given Node. Always false for text values as these represent as-of-yet
|
||||
// created Text nodes.
|
||||
fn is(self: NodeOrText, other: *parser.Node) bool {
|
||||
return switch (self) {
|
||||
.text => false,
|
||||
.node => |n| n == other,
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
52
src/browser/dom/node_filter.zig
Normal file
52
src/browser/dom/node_filter.zig
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const NodeFilter = struct {
|
||||
pub const _FILTER_ACCEPT: u16 = 1;
|
||||
pub const _FILTER_REJECT: u16 = 2;
|
||||
pub const _FILTER_SKIP: u16 = 3;
|
||||
pub const _SHOW_ALL: u32 = std.math.maxInt(u32);
|
||||
pub const _SHOW_ELEMENT: u32 = 0b1;
|
||||
pub const _SHOW_ATTRIBUTE: u32 = 0b10;
|
||||
pub const _SHOW_TEXT: u32 = 0b100;
|
||||
pub const _SHOW_CDATA_SECTION: u32 = 0b1000;
|
||||
pub const _SHOW_ENTITY_REFERENCE: u32 = 0b10000;
|
||||
pub const _SHOW_ENTITY: u32 = 0b100000;
|
||||
pub const _SHOW_PROCESSING_INSTRUCTION: u32 = 0b1000000;
|
||||
pub const _SHOW_COMMENT: u32 = 0b10000000;
|
||||
pub const _SHOW_DOCUMENT: u32 = 0b100000000;
|
||||
pub const _SHOW_DOCUMENT_TYPE: u32 = 0b1000000000;
|
||||
pub const _SHOW_DOCUMENT_FRAGMENT: u32 = 0b10000000000;
|
||||
pub const _SHOW_NOTATION: u32 = 0b100000000000;
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.NodeFilter" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "NodeFilter.FILTER_ACCEPT", "1" },
|
||||
.{ "NodeFilter.FILTER_REJECT", "2" },
|
||||
.{ "NodeFilter.FILTER_SKIP", "3" },
|
||||
.{ "NodeFilter.SHOW_ALL", "4294967295" },
|
||||
.{ "NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT", "129" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const JsThis = @import("../env.zig").JsThis;
|
||||
const Callback = @import("../env.zig").Callback;
|
||||
const Function = @import("../env.zig").Function;
|
||||
|
||||
const NodeUnion = @import("node.zig").Union;
|
||||
const Node = @import("node.zig").Node;
|
||||
@@ -141,11 +141,11 @@ pub const NodeList = struct {
|
||||
// };
|
||||
// }
|
||||
|
||||
pub fn _forEach(self: *NodeList, cbk: Callback) !void { // TODO handle thisArg
|
||||
pub fn _forEach(self: *NodeList, cbk: Function) !void { // TODO handle thisArg
|
||||
for (self.nodes.items, 0..) |n, i| {
|
||||
const ii: u32 = @intCast(i);
|
||||
var result: Callback.Result = undefined;
|
||||
cbk.tryCall(.{ n, ii, self }, &result) catch {
|
||||
var result: Function.Result = undefined;
|
||||
cbk.tryCall(void, .{ n, ii, self }, &result) catch {
|
||||
log.err("callback error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
@@ -173,7 +173,7 @@ pub const NodeList = struct {
|
||||
const len = self.get_length();
|
||||
for (0..len) |i| {
|
||||
const node = try self._item(@intCast(i)) orelse unreachable;
|
||||
try js_this.setIndex(i, node);
|
||||
try js_this.setIndex(@intCast(i), node, .{});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ pub const ProcessingInstruction = struct {
|
||||
// a simple workaround.
|
||||
pub fn _cloneNode(self: *parser.ProcessingInstruction, _: ?bool, state: *SessionState) !*parser.ProcessingInstruction {
|
||||
return try parser.documentCreateProcessingInstruction(
|
||||
@ptrCast(state.document),
|
||||
@ptrCast(state.window.document),
|
||||
try get_target(self),
|
||||
(try get_data(self)) orelse "",
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ pub const Text = struct {
|
||||
|
||||
pub fn constructor(data: ?[]const u8, state: *const SessionState) !*parser.Text {
|
||||
return parser.documentCreateTextNode(
|
||||
parser.documentHTMLToDocument(state.document.?),
|
||||
parser.documentHTMLToDocument(state.window.document),
|
||||
data orelse "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const iterator = @import("../iterator/iterator.zig");
|
||||
|
||||
const Callback = @import("../env.zig").Callback;
|
||||
const Function = @import("../env.zig").Function;
|
||||
const JsObject = @import("../env.zig").JsObject;
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
|
||||
@@ -138,11 +138,11 @@ pub const DOMTokenList = struct {
|
||||
}
|
||||
|
||||
// TODO handle thisArg
|
||||
pub fn _forEach(self: *parser.TokenList, cbk: Callback, this_arg: JsObject) !void {
|
||||
pub fn _forEach(self: *parser.TokenList, cbk: Function, this_arg: JsObject) !void {
|
||||
var entries = _entries(self);
|
||||
while (try entries._next()) |entry| {
|
||||
var result: Callback.Result = undefined;
|
||||
cbk.tryCallWithThis(this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch {
|
||||
var result: Function.Result = undefined;
|
||||
cbk.tryCallWithThis(void, this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch {
|
||||
log.err("callback error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
|
||||
293
src/browser/dom/tree_walker.zig
Normal file
293
src/browser/dom/tree_walker.zig
Normal file
@@ -0,0 +1,293 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const NodeFilter = @import("node_filter.zig").NodeFilter;
|
||||
const Env = @import("../env.zig").Env;
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
|
||||
pub const TreeWalker = struct {
|
||||
root: *parser.Node,
|
||||
current_node: *parser.Node,
|
||||
what_to_show: u32,
|
||||
filter: ?Env.Function,
|
||||
|
||||
pub const TreeWalkerOpts = union(enum) {
|
||||
function: Env.Function,
|
||||
object: struct { acceptNode: Env.Function },
|
||||
};
|
||||
|
||||
pub fn init(node: *parser.Node, what_to_show: ?u32, filter: ?TreeWalkerOpts) !TreeWalker {
|
||||
var filter_func: ?Env.Function = null;
|
||||
|
||||
if (filter) |f| {
|
||||
filter_func = switch (f) {
|
||||
.function => |func| func,
|
||||
.object => |o| o.acceptNode,
|
||||
};
|
||||
}
|
||||
|
||||
return .{
|
||||
.root = node,
|
||||
.current_node = node,
|
||||
.what_to_show = what_to_show orelse NodeFilter._SHOW_ALL,
|
||||
.filter = filter_func,
|
||||
};
|
||||
}
|
||||
|
||||
const VerifyResult = enum { accept, skip, reject };
|
||||
|
||||
fn verify(self: *const TreeWalker, node: *parser.Node) !VerifyResult {
|
||||
const node_type = try parser.nodeType(node);
|
||||
const what_to_show = self.what_to_show;
|
||||
|
||||
// Verify that we can show this node type.
|
||||
if (!switch (node_type) {
|
||||
.attribute => what_to_show & NodeFilter._SHOW_ATTRIBUTE != 0,
|
||||
.cdata_section => what_to_show & NodeFilter._SHOW_CDATA_SECTION != 0,
|
||||
.comment => what_to_show & NodeFilter._SHOW_COMMENT != 0,
|
||||
.document => what_to_show & NodeFilter._SHOW_DOCUMENT != 0,
|
||||
.document_fragment => what_to_show & NodeFilter._SHOW_DOCUMENT_FRAGMENT != 0,
|
||||
.document_type => what_to_show & NodeFilter._SHOW_DOCUMENT_TYPE != 0,
|
||||
.element => what_to_show & NodeFilter._SHOW_ELEMENT != 0,
|
||||
.entity => what_to_show & NodeFilter._SHOW_ENTITY != 0,
|
||||
.entity_reference => what_to_show & NodeFilter._SHOW_ENTITY_REFERENCE != 0,
|
||||
.notation => what_to_show & NodeFilter._SHOW_NOTATION != 0,
|
||||
.processing_instruction => what_to_show & NodeFilter._SHOW_PROCESSING_INSTRUCTION != 0,
|
||||
.text => what_to_show & NodeFilter._SHOW_TEXT != 0,
|
||||
}) return .reject;
|
||||
|
||||
// Verify that we aren't filtering it out.
|
||||
if (self.filter) |f| {
|
||||
const filter = try f.call(u32, .{node});
|
||||
return switch (filter) {
|
||||
NodeFilter._FILTER_ACCEPT => .accept,
|
||||
NodeFilter._FILTER_REJECT => .reject,
|
||||
NodeFilter._FILTER_SKIP => .skip,
|
||||
else => .reject,
|
||||
};
|
||||
} else return .accept;
|
||||
}
|
||||
|
||||
pub fn get_root(self: *TreeWalker) *parser.Node {
|
||||
return self.root;
|
||||
}
|
||||
|
||||
pub fn get_currentNode(self: *TreeWalker) *parser.Node {
|
||||
return self.current_node;
|
||||
}
|
||||
|
||||
pub fn get_whatToShow(self: *TreeWalker) u32 {
|
||||
return self.what_to_show;
|
||||
}
|
||||
|
||||
pub fn get_filter(self: *TreeWalker) ?Env.Function {
|
||||
return self.filter;
|
||||
}
|
||||
|
||||
pub fn set_currentNode(self: *TreeWalker, node: *parser.Node) !void {
|
||||
self.current_node = node;
|
||||
}
|
||||
|
||||
fn firstChild(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
|
||||
const children = try parser.nodeGetChildNodes(node);
|
||||
const child_count = try parser.nodeListLength(children);
|
||||
|
||||
for (0..child_count) |i| {
|
||||
const index: u32 = @intCast(i);
|
||||
const child = (try parser.nodeListItem(children, index)) orelse return null;
|
||||
|
||||
switch (try self.verify(child)) {
|
||||
.accept => return child,
|
||||
.reject => continue,
|
||||
.skip => if (try self.firstChild(child)) |gchild| return gchild,
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn lastChild(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
|
||||
const children = try parser.nodeGetChildNodes(node);
|
||||
const child_count = try parser.nodeListLength(children);
|
||||
|
||||
var index: u32 = child_count;
|
||||
while (index > 0) {
|
||||
index -= 1;
|
||||
const child = (try parser.nodeListItem(children, index)) orelse return null;
|
||||
|
||||
switch (try self.verify(child)) {
|
||||
.accept => return child,
|
||||
.reject => continue,
|
||||
.skip => if (try self.lastChild(child)) |gchild| return gchild,
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn nextSibling(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
|
||||
var current = node;
|
||||
|
||||
while (true) {
|
||||
current = (try parser.nodeNextSibling(current)) orelse return null;
|
||||
|
||||
switch (try self.verify(current)) {
|
||||
.accept => return current,
|
||||
.skip, .reject => continue,
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn previousSibling(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
|
||||
var current = node;
|
||||
|
||||
while (true) {
|
||||
current = (try parser.nodePreviousSibling(current)) orelse return null;
|
||||
|
||||
switch (try self.verify(current)) {
|
||||
.accept => return current,
|
||||
.skip, .reject => continue,
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn parentNode(self: *const TreeWalker, node: *parser.Node) !?*parser.Node {
|
||||
if (self.root == node) return null;
|
||||
|
||||
var current = node;
|
||||
while (true) {
|
||||
if (current == self.root) return null;
|
||||
current = (try parser.nodeParentNode(current)) orelse return null;
|
||||
|
||||
switch (try self.verify(current)) {
|
||||
.accept => return current,
|
||||
.reject, .skip => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _firstChild(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.firstChild(self.current_node)) |child| {
|
||||
self.current_node = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _lastChild(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.lastChild(self.current_node)) |child| {
|
||||
self.current_node = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _nextNode(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.firstChild(self.current_node)) |child| {
|
||||
self.current_node = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
var current = self.current_node;
|
||||
while (current != self.root) {
|
||||
if (try self.nextSibling(current)) |sibling| {
|
||||
self.current_node = sibling;
|
||||
return sibling;
|
||||
}
|
||||
|
||||
current = (try parser.nodeParentNode(current)) orelse break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _nextSibling(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.nextSibling(self.current_node)) |sibling| {
|
||||
self.current_node = sibling;
|
||||
return sibling;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _parentNode(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.parentNode(self.current_node)) |parent| {
|
||||
self.current_node = parent;
|
||||
return parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _previousNode(self: *TreeWalker) !?*parser.Node {
|
||||
var current = self.current_node;
|
||||
while (try parser.nodePreviousSibling(current)) |previous| {
|
||||
current = previous;
|
||||
|
||||
switch (try self.verify(current)) {
|
||||
.accept => {
|
||||
// Get last child if it has one.
|
||||
if (try self.lastChild(current)) |child| {
|
||||
self.current_node = child;
|
||||
return child;
|
||||
}
|
||||
|
||||
// Otherwise, this node is our previous one.
|
||||
self.current_node = current;
|
||||
return current;
|
||||
},
|
||||
.reject => continue,
|
||||
.skip => {
|
||||
// Get last child if it has one.
|
||||
if (try self.lastChild(current)) |child| {
|
||||
self.current_node = child;
|
||||
return child;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (current != self.root) {
|
||||
if (try self.parentNode(current)) |parent| {
|
||||
self.current_node = parent;
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _previousSibling(self: *TreeWalker) !?*parser.Node {
|
||||
if (try self.previousSibling(self.current_node)) |sibling| {
|
||||
self.current_node = sibling;
|
||||
return sibling;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
64
src/browser/encoding/text_encoder.zig
Normal file
64
src/browser/encoding/text_encoder.zig
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
pub const Interfaces = .{
|
||||
TextEncoder,
|
||||
};
|
||||
|
||||
// https://encoding.spec.whatwg.org/#interface-textencoder
|
||||
pub const TextEncoder = struct {
|
||||
pub fn constructor() !TextEncoder {
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn get_encoding(_: *const TextEncoder) []const u8 {
|
||||
return "utf-8";
|
||||
}
|
||||
|
||||
pub fn _encode(_: *const TextEncoder, v: []const u8) !Env.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;
|
||||
}
|
||||
|
||||
return .{ .values = v };
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Encoding.TextEncoder" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var encoder = new TextEncoder();", "undefined" },
|
||||
.{ "encoder.encoding;", "utf-8" },
|
||||
.{ "encoder.encode('€');", "226,130,172" },
|
||||
|
||||
// Invalid utf-8 sequence.
|
||||
// Result with chrome:
|
||||
// .{ "encoder.encode(new Uint8Array([0xE2,0x28,0xA1]))", "50,50,54,44,52,48,44,49,54,49" },
|
||||
.{ "try {encoder.encode(new Uint8Array([0xE2,0x28,0xA1])) } catch (e) { e };", "Error: InvalidUtf8" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -5,9 +5,9 @@ const URL = @import("../url.zig").URL;
|
||||
const js = @import("../runtime/js.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
const generate = @import("../runtime/generate.zig");
|
||||
const Renderer = @import("renderer.zig").Renderer;
|
||||
const Loop = @import("../runtime/loop.zig").Loop;
|
||||
const HttpClient = @import("../http/client.zig").Client;
|
||||
const Renderer = @import("browser.zig").Renderer;
|
||||
const RequestFactory = @import("../http/client.zig").RequestFactory;
|
||||
|
||||
const WebApis = struct {
|
||||
// Wrapped like this for debug ergonomics.
|
||||
@@ -27,34 +27,58 @@ const WebApis = struct {
|
||||
pub const Interfaces = generate.Tuple(.{
|
||||
@import("crypto/crypto.zig").Crypto,
|
||||
@import("console/console.zig").Console,
|
||||
@import("cssom/css_style_declaration.zig").Interfaces,
|
||||
@import("dom/dom.zig").Interfaces,
|
||||
@import("encoding/text_encoder.zig").Interfaces,
|
||||
@import("events/event.zig").Interfaces,
|
||||
@import("html/html.zig").Interfaces,
|
||||
@import("iterator/iterator.zig").Interfaces,
|
||||
@import("storage/storage.zig").Interfaces,
|
||||
@import("url/url.zig").Interfaces,
|
||||
@import("xhr/xhr.zig").Interfaces,
|
||||
@import("xhr/form_data.zig").Interfaces,
|
||||
@import("xmlserializer/xmlserializer.zig").Interfaces,
|
||||
});
|
||||
};
|
||||
|
||||
pub const JsThis = Env.JsThis;
|
||||
pub const JsObject = Env.JsObject;
|
||||
pub const Callback = Env.Callback;
|
||||
pub const Function = Env.Function;
|
||||
pub const Env = js.Env(*SessionState, WebApis);
|
||||
pub const Global = @import("html/window.zig").Window;
|
||||
|
||||
const Window = @import("html/window.zig").Window;
|
||||
pub const Global = Window;
|
||||
|
||||
pub const SessionState = struct {
|
||||
loop: *Loop,
|
||||
url: *const URL,
|
||||
window: *Window,
|
||||
renderer: *Renderer,
|
||||
arena: std.mem.Allocator,
|
||||
http_client: *HttpClient,
|
||||
cookie_jar: *storage.CookieJar,
|
||||
document: ?*parser.DocumentHTML,
|
||||
request_factory: RequestFactory,
|
||||
|
||||
// dangerous, but set by the JS framework
|
||||
// shorter-lived than the arena above, which
|
||||
// exists for the entire rendering of the page
|
||||
call_arena: std.mem.Allocator = undefined,
|
||||
|
||||
pub fn getOrCreateNodeWrapper(self: *SessionState, comptime T: type, node: *parser.Node) !*T {
|
||||
if (try self.getNodeWrapper(T, node)) |wrap| {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const wrap = try self.arena.create(T);
|
||||
wrap.* = T{};
|
||||
|
||||
parser.nodeSetEmbedderData(node, wrap);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
pub fn getNodeWrapper(_: *SessionState, comptime T: type, node: *parser.Node) !?*T {
|
||||
if (parser.nodeGetEmbedderData(node)) |wrap| {
|
||||
return @alignCast(@ptrCast(wrap));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Callback = @import("../env.zig").Callback;
|
||||
const Function = @import("../env.zig").Function;
|
||||
const generate = @import("../../runtime/generate.zig");
|
||||
|
||||
const DOMException = @import("../dom/exceptions.zig").DOMException;
|
||||
@@ -140,10 +140,10 @@ pub const Event = struct {
|
||||
};
|
||||
|
||||
pub const EventHandler = struct {
|
||||
callback: Callback,
|
||||
callback: Function,
|
||||
node: parser.EventNode,
|
||||
|
||||
pub fn init(allocator: Allocator, callback: Callback) !*EventHandler {
|
||||
pub fn init(allocator: Allocator, callback: Function) !*EventHandler {
|
||||
const eh = try allocator.create(EventHandler);
|
||||
eh.* = .{
|
||||
.callback = callback,
|
||||
@@ -162,8 +162,8 @@ pub const EventHandler = struct {
|
||||
};
|
||||
|
||||
const self: *EventHandler = @fieldParentPtr("node", node);
|
||||
var result: Callback.Result = undefined;
|
||||
self.callback.tryCall(.{ievent}, &result) catch {
|
||||
var result: Function.Result = undefined;
|
||||
self.callback.tryCall(void, .{ievent}, &result) catch {
|
||||
log.err("event handler error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
|
||||
@@ -21,6 +21,9 @@ const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
const Window = @import("window.zig").Window;
|
||||
const Element = @import("../dom/element.zig").Element;
|
||||
const ElementUnion = @import("../dom/element.zig").Union;
|
||||
const Document = @import("../dom/document.zig").Document;
|
||||
const NodeList = @import("../dom/nodelist.zig").NodeList;
|
||||
const Location = @import("location.zig").Location;
|
||||
@@ -35,6 +38,14 @@ pub const HTMLDocument = struct {
|
||||
pub const prototype = *Document;
|
||||
pub const subtype = .node;
|
||||
|
||||
ready_state: ReadyState = .loading,
|
||||
|
||||
const ReadyState = enum {
|
||||
loading,
|
||||
interactive,
|
||||
complete,
|
||||
};
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
@@ -151,8 +162,8 @@ pub const HTMLDocument = struct {
|
||||
return try collection.HTMLCollectionByAnchors(parser.documentHTMLToNode(self), false);
|
||||
}
|
||||
|
||||
pub fn get_all(self: *parser.DocumentHTML) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionAll(parser.documentHTMLToNode(self), true);
|
||||
pub fn get_all(self: *parser.DocumentHTML) collection.HTMLAllCollection {
|
||||
return collection.HTMLAllCollection.init(parser.documentHTMLToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_currentScript(self: *parser.DocumentHTML) !?*parser.Script {
|
||||
@@ -171,6 +182,15 @@ pub const HTMLDocument = struct {
|
||||
return "off";
|
||||
}
|
||||
|
||||
pub fn get_defaultView(_: *parser.DocumentHTML, state: *const SessionState) *Window {
|
||||
return state.window;
|
||||
}
|
||||
|
||||
pub fn get_readyState(node: *parser.DocumentHTML, state: *SessionState) ![]const u8 {
|
||||
const self = try state.getOrCreateNodeWrapper(HTMLDocument, @ptrCast(node));
|
||||
return @tagName(self.ready_state);
|
||||
}
|
||||
|
||||
// noop legacy functions
|
||||
// https://html.spec.whatwg.org/#Document-partial
|
||||
pub fn _clear(_: *parser.DocumentHTML) void {}
|
||||
@@ -207,6 +227,59 @@ pub const HTMLDocument = struct {
|
||||
pub fn set_bgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Returns the topmost Element at the specified coordinates (relative to the viewport).
|
||||
// Since LightPanda requires the client to know what they are clicking on we do not return the underlying element at this moment
|
||||
// This can currenty only happen if the first pixel is clicked without having rendered any element. This will change when css properties are supported.
|
||||
// This returns an ElementUnion instead of a *Parser.Element in case the element somehow hasn't passed through the js runtime yet.
|
||||
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, state: *SessionState) !?ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = state.renderer.getElementAtPosition(ix, iy) orelse return null;
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
return try Element.toInterface(element);
|
||||
}
|
||||
|
||||
// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
|
||||
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, state: *SessionState) ![]ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = state.renderer.getElementAtPosition(ix, iy) orelse return &.{};
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
|
||||
var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
|
||||
try list.ensureTotalCapacity(state.call_arena, 3);
|
||||
list.appendAssumeCapacity(try Element.toInterface(element));
|
||||
|
||||
// Since we are using a flat renderer there is no hierarchy of elements. What we do know is that the element is part of the main document.
|
||||
// Thus we can add the HtmlHtmlElement and it's child HTMLBodyElement to the returned list.
|
||||
// TBD Should we instead return every parent that is an element? Note that a child does not physically need to be overlapping the parent.
|
||||
// Should we do a render pass on demand?
|
||||
const doc_elem = try parser.documentGetDocumentElement(parser.documentHTMLToDocument(state.window.document)) orelse {
|
||||
return list.items;
|
||||
};
|
||||
if (try parser.documentHTMLBody(state.window.document)) |body| {
|
||||
list.appendAssumeCapacity(try Element.toInterface(parser.bodyToElement(body)));
|
||||
}
|
||||
list.appendAssumeCapacity(try Element.toInterface(doc_elem));
|
||||
return list.items;
|
||||
}
|
||||
|
||||
pub fn documentIsLoaded(html_doc: *parser.DocumentHTML, state: *SessionState) !void {
|
||||
const self = try state.getOrCreateNodeWrapper(HTMLDocument, @ptrCast(html_doc));
|
||||
self.ready_state = .interactive;
|
||||
|
||||
const evt = try parser.eventCreate();
|
||||
defer parser.eventDestroy(evt);
|
||||
|
||||
try parser.eventInit(evt, "DOMContentLoaded", .{ .bubbles = true, .cancelable = true });
|
||||
_ = try parser.eventTargetDispatchEvent(parser.toEventTarget(parser.DocumentHTML, html_doc), evt);
|
||||
}
|
||||
|
||||
pub fn documentIsComplete(html_doc: *parser.DocumentHTML, state: *SessionState) !void {
|
||||
const self = try state.getOrCreateNodeWrapper(HTMLDocument, @ptrCast(html_doc));
|
||||
self.ready_state = .complete;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
@@ -260,4 +333,63 @@ test "Browser.HTML.Document" {
|
||||
.{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
|
||||
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.elementFromPoint(0.5, 0.5)", "null" }, // Return null since we only return element s when they have previously been localized
|
||||
.{ "document.elementsFromPoint(0.5, 0.5)", "" },
|
||||
.{
|
||||
\\ let div1 = document.createElement('div');
|
||||
\\ document.body.appendChild(div1);
|
||||
\\ div1.getClientRects();
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "document.elementFromPoint(0.5, 0.5)", "[object HTMLDivElement]" },
|
||||
.{ "let elems = document.elementsFromPoint(0.5, 0.5)", null },
|
||||
.{ "elems.length", "3" },
|
||||
.{ "elems[0]", "[object HTMLDivElement]" },
|
||||
.{ "elems[1]", "[object HTMLBodyElement]" },
|
||||
.{ "elems[2]", "[object HTMLHtmlElement]" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ let a = document.createElement('a');
|
||||
\\ a.href = "https://lightpanda.io";
|
||||
\\ document.body.appendChild(a);
|
||||
\\ a.getClientRects();
|
||||
, // Note this will be placed after the div of previous test
|
||||
null,
|
||||
},
|
||||
.{ "let a_again = document.elementFromPoint(1.5, 0.5)", null },
|
||||
.{ "a_again", "[object HTMLAnchorElement]" },
|
||||
.{ "a_again.href", "https://lightpanda.io" },
|
||||
.{ "let a_agains = document.elementsFromPoint(1.5, 0.5)", null },
|
||||
.{ "a_agains[0].href", "https://lightpanda.io" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "!document.all", "true" },
|
||||
.{ "!!document.all", "false" },
|
||||
.{ "document.all(5)", "[object HTMLParagraphElement]" },
|
||||
.{ "document.all('content')", "[object HTMLDivElement]" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.defaultView.document == document", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.readyState", "loading" },
|
||||
}, .{});
|
||||
|
||||
try HTMLDocument.documentIsLoaded(runner.window.document, &runner.state);
|
||||
try runner.testCases(&.{
|
||||
.{ "document.readyState", "interactive" },
|
||||
}, .{});
|
||||
|
||||
try HTMLDocument.documentIsComplete(runner.window.document, &runner.state);
|
||||
try runner.testCases(&.{
|
||||
.{ "document.readyState", "complete" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ const URL = @import("../url/url.zig").URL;
|
||||
const Node = @import("../dom/node.zig").Node;
|
||||
const Element = @import("../dom/element.zig").Element;
|
||||
|
||||
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
|
||||
// HTMLElement interfaces
|
||||
pub const Interfaces = .{
|
||||
HTMLElement,
|
||||
@@ -47,7 +49,6 @@ pub const Interfaces = .{
|
||||
HTMLEmbedElement,
|
||||
HTMLFieldSetElement,
|
||||
HTMLFontElement,
|
||||
HTMLFormElement,
|
||||
HTMLFrameElement,
|
||||
HTMLFrameSetElement,
|
||||
HTMLHRElement,
|
||||
@@ -77,7 +78,6 @@ pub const Interfaces = .{
|
||||
HTMLProgressElement,
|
||||
HTMLQuoteElement,
|
||||
HTMLScriptElement,
|
||||
HTMLSelectElement,
|
||||
HTMLSourceElement,
|
||||
HTMLSpanElement,
|
||||
HTMLStyleElement,
|
||||
@@ -94,7 +94,9 @@ pub const Interfaces = .{
|
||||
HTMLTrackElement,
|
||||
HTMLUListElement,
|
||||
HTMLVideoElement,
|
||||
CSSProperties,
|
||||
|
||||
@import("form.zig").HTMLFormElement,
|
||||
@import("select.zig").HTMLSelectElement,
|
||||
};
|
||||
|
||||
pub const Union = generate.Union(Interfaces);
|
||||
@@ -102,15 +104,19 @@ pub const Union = generate.Union(Interfaces);
|
||||
// Abstract class
|
||||
// --------------
|
||||
|
||||
const CSSProperties = struct {};
|
||||
|
||||
pub const HTMLElement = struct {
|
||||
pub const Self = parser.ElementHTML;
|
||||
pub const prototype = *Element;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn get_style(_: *parser.ElementHTML) CSSProperties {
|
||||
return .{};
|
||||
style: CSSStyleDeclaration = .{
|
||||
.store = .{},
|
||||
.order = .{},
|
||||
},
|
||||
|
||||
pub fn get_style(e: *parser.ElementHTML, state: *SessionState) !*CSSStyleDeclaration {
|
||||
const self = try state.getOrCreateNodeWrapper(HTMLElement, @ptrCast(e));
|
||||
return &self.style;
|
||||
}
|
||||
|
||||
pub fn get_innerText(e: *parser.ElementHTML) ![]const u8 {
|
||||
@@ -516,12 +522,6 @@ pub const HTMLFontElement = struct {
|
||||
pub const subtype = .node;
|
||||
};
|
||||
|
||||
pub const HTMLFormElement = struct {
|
||||
pub const Self = parser.Form;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
};
|
||||
|
||||
pub const HTMLFrameElement = struct {
|
||||
pub const Self = parser.Frame;
|
||||
pub const prototype = *HTMLElement;
|
||||
@@ -568,6 +568,52 @@ pub const HTMLImageElement = struct {
|
||||
pub const Self = parser.Image;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
pub const js_name = "Image";
|
||||
|
||||
pub fn constructor(width: ?u32, height: ?u32, state: *const SessionState) !*parser.Image {
|
||||
const element = try parser.documentCreateElement(parser.documentHTMLToDocument(state.window.document), "img");
|
||||
const image: *parser.Image = @ptrCast(element);
|
||||
if (width) |width_| try parser.imageSetWidth(image, width_);
|
||||
if (height) |height_| try parser.imageSetHeight(image, height_);
|
||||
return image;
|
||||
}
|
||||
|
||||
pub fn get_alt(self: *parser.Image) ![]const u8 {
|
||||
return try parser.imageGetAlt(self);
|
||||
}
|
||||
pub fn set_alt(self: *parser.Image, alt: []const u8) !void {
|
||||
try parser.imageSetAlt(self, alt);
|
||||
}
|
||||
pub fn get_src(self: *parser.Image) ![]const u8 {
|
||||
return try parser.imageGetSrc(self);
|
||||
}
|
||||
pub fn set_src(self: *parser.Image, src: []const u8) !void {
|
||||
try parser.imageSetSrc(self, src);
|
||||
}
|
||||
pub fn get_useMap(self: *parser.Image) ![]const u8 {
|
||||
return try parser.imageGetUseMap(self);
|
||||
}
|
||||
pub fn set_useMap(self: *parser.Image, use_map: []const u8) !void {
|
||||
try parser.imageSetUseMap(self, use_map);
|
||||
}
|
||||
pub fn get_height(self: *parser.Image) !u32 {
|
||||
return try parser.imageGetHeight(self);
|
||||
}
|
||||
pub fn set_height(self: *parser.Image, height: u32) !void {
|
||||
try parser.imageSetHeight(self, height);
|
||||
}
|
||||
pub fn get_width(self: *parser.Image) !u32 {
|
||||
return try parser.imageGetWidth(self);
|
||||
}
|
||||
pub fn set_width(self: *parser.Image, width: u32) !void {
|
||||
try parser.imageSetWidth(self, width);
|
||||
}
|
||||
pub fn get_isMap(self: *parser.Image) !bool {
|
||||
return try parser.imageGetIsMap(self);
|
||||
}
|
||||
pub fn set_isMap(self: *parser.Image, is_map: bool) !void {
|
||||
try parser.imageSetIsMap(self, is_map);
|
||||
}
|
||||
};
|
||||
|
||||
pub const HTMLInputElement = struct {
|
||||
@@ -806,12 +852,6 @@ pub const HTMLScriptElement = struct {
|
||||
}
|
||||
};
|
||||
|
||||
pub const HTMLSelectElement = struct {
|
||||
pub const Self = parser.Select;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
};
|
||||
|
||||
pub const HTMLSourceElement = struct {
|
||||
pub const Self = parser.Source;
|
||||
pub const prototype = *HTMLElement;
|
||||
@@ -1070,4 +1110,40 @@ test "Browser.HTML.Element" {
|
||||
.{ "document.getElementById('content').click()", "undefined" },
|
||||
.{ "click_count", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let style = document.getElementById('content').style", "undefined" },
|
||||
.{ "style.cssText = 'color: red; font-size: 12px; margin: 5px !important;'", "color: red; font-size: 12px; margin: 5px !important;" },
|
||||
.{ "style.length", "3" },
|
||||
.{ "style.setProperty('background-color', 'blue')", "undefined" },
|
||||
.{ "style.getPropertyValue('background-color')", "blue" },
|
||||
.{ "style.length", "4" },
|
||||
}, .{});
|
||||
|
||||
// Image
|
||||
try runner.testCases(&.{
|
||||
// Testing constructors
|
||||
.{ "(new Image).width", "0" },
|
||||
.{ "(new Image).height", "0" },
|
||||
.{ "(new Image(4)).width", "4" },
|
||||
.{ "(new Image(4, 6)).height", "6" },
|
||||
|
||||
// Testing ulong property
|
||||
.{ "let fruit = new Image", null },
|
||||
.{ "fruit.width", "0" },
|
||||
.{ "fruit.width = 5", "5" },
|
||||
.{ "fruit.width", "5" },
|
||||
.{ "fruit.width = '15'", "15" },
|
||||
.{ "fruit.width", "15" },
|
||||
.{ "fruit.width = 'apple'", "apple" },
|
||||
.{ "fruit.width;", "0" },
|
||||
|
||||
// Testing string property
|
||||
.{ "let lyric = new Image", null },
|
||||
.{ "lyric.src", "" },
|
||||
.{ "lyric.src = 'okay'", "okay" },
|
||||
.{ "lyric.src", "okay" },
|
||||
.{ "lyric.src = 15", "15" },
|
||||
.{ "lyric.src", "15" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
54
src/browser/html/form.zig
Normal file
54
src/browser/html/form.zig
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const HTMLElement = @import("elements.zig").HTMLElement;
|
||||
const FormData = @import("../xhr/form_data.zig").FormData;
|
||||
|
||||
pub const HTMLFormElement = struct {
|
||||
pub const Self = parser.Form;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn _requestSubmit(self: *parser.Form) !void {
|
||||
try parser.formElementSubmit(self);
|
||||
}
|
||||
|
||||
pub fn _reset(self: *parser.Form) !void {
|
||||
try parser.formElementReset(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Submission = struct {
|
||||
method: ?[]const u8,
|
||||
form_data: FormData,
|
||||
};
|
||||
|
||||
pub fn processSubmission(arena: Allocator, form: *parser.Form) !?Submission {
|
||||
const form_element: *parser.Element = @ptrCast(form);
|
||||
const method = try parser.elementGetAttribute(form_element, "method");
|
||||
|
||||
return .{
|
||||
.method = method,
|
||||
.form_data = try FormData.fromForm(arena, form),
|
||||
};
|
||||
}
|
||||
|
||||
// Check xhr/form_data.zig for tests
|
||||
@@ -23,6 +23,9 @@ const Window = @import("window.zig").Window;
|
||||
const Navigator = @import("navigator.zig").Navigator;
|
||||
const History = @import("history.zig").History;
|
||||
const Location = @import("location.zig").Location;
|
||||
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
||||
const Performance = @import("performance.zig").Performance;
|
||||
const TrustedTypes = @import("trusted_types.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
HTMLDocument,
|
||||
@@ -34,4 +37,7 @@ pub const Interfaces = .{
|
||||
Navigator,
|
||||
History,
|
||||
Location,
|
||||
MediaQueryList,
|
||||
Performance,
|
||||
TrustedTypes.Interfaces,
|
||||
};
|
||||
|
||||
45
src/browser/html/media_query_list.zig
Normal file
45
src/browser/html/media_query_list.zig
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Function = @import("../env.zig").Function;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
// https://drafts.csswg.org/cssom-view/#the-mediaquerylist-interface
|
||||
pub const MediaQueryList = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
// This is not safe as it relies on a structure layout that isn't guaranteed
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
matches: bool,
|
||||
media: []const u8,
|
||||
|
||||
pub fn get_matches(self: *const MediaQueryList) bool {
|
||||
return self.matches;
|
||||
}
|
||||
|
||||
pub fn get_media(self: *const MediaQueryList) []const u8 {
|
||||
return self.media;
|
||||
}
|
||||
|
||||
pub fn _addListener(_: *const MediaQueryList, _: Function) void {}
|
||||
|
||||
pub fn _removeListener(_: *const MediaQueryList, _: Function) void {}
|
||||
};
|
||||
87
src/browser/html/performance.zig
Normal file
87
src/browser/html/performance.zig
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
|
||||
pub const Performance = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
time_origin: std.time.Timer,
|
||||
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
|
||||
// else -> Resolution in non-isolated contexts: 100 microseconds
|
||||
const ms_resolution = 100;
|
||||
|
||||
fn limitedResolutionMs(nanoseconds: u64) f64 {
|
||||
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
|
||||
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
|
||||
return elapsed / @as(f64, std.time.us_per_ms);
|
||||
}
|
||||
|
||||
pub fn get_timeOrigin(self: *const Performance) f64 {
|
||||
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
|
||||
.windows, .uefi, .wasi => false,
|
||||
else => true,
|
||||
};
|
||||
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
|
||||
const started = self.time_origin.started.since(zero);
|
||||
return limitedResolutionMs(started);
|
||||
}
|
||||
|
||||
pub fn _now(self: *Performance) f64 {
|
||||
return limitedResolutionMs(self.time_origin.read());
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("./../../testing.zig");
|
||||
|
||||
test "Performance: get_timeOrigin" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
const time_origin = perf.get_timeOrigin();
|
||||
try testing.expect(time_origin >= 0);
|
||||
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
|
||||
test "Performance: now" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
|
||||
// Monotonically increasing
|
||||
var now = perf._now();
|
||||
while (now <= 0) { // Loop for now to not be 0
|
||||
try testing.expectEqual(now, 0);
|
||||
now = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
|
||||
var after = perf._now();
|
||||
while (after <= now) { // Loop untill after > now
|
||||
try testing.expectEqual(after, now);
|
||||
after = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
158
src/browser/html/select.zig
Normal file
158
src/browser/html/select.zig
Normal file
@@ -0,0 +1,158 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const HTMLElement = @import("elements.zig").HTMLElement;
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
pub const HTMLSelectElement = struct {
|
||||
pub const Self = parser.Select;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
|
||||
// By default, if no option is explicitly selected, the first option should
|
||||
// be selected. However, libdom doesn't do this, and it sets the
|
||||
// selectedIndex to -1, which is a valid value for "nothing selected".
|
||||
// Therefore, when libdom says the selectedIndex == -1, we don't know if
|
||||
// it means that nothing is selected, or if the first option is selected by
|
||||
// default.
|
||||
// There are cases where this won't work, but when selectedIndex is
|
||||
// explicitly set, we set this boolean flag. Then, when we're getting then
|
||||
// selectedIndex, if this flag is == false, which is to say that if
|
||||
// selectedIndex hasn't been explicitly set AND if we have at least 1 option
|
||||
// AND if it isn't a multi select, we can make the 1st item selected by
|
||||
// default (by returning selectedIndex == 0).
|
||||
explicit_index_set: bool = false,
|
||||
|
||||
pub fn get_length(select: *parser.Select) !u32 {
|
||||
return parser.selectGetLength(select);
|
||||
}
|
||||
|
||||
pub fn get_form(select: *parser.Select) !?*parser.Form {
|
||||
return parser.selectGetForm(select);
|
||||
}
|
||||
|
||||
pub fn get_name(select: *parser.Select) ![]const u8 {
|
||||
return parser.selectGetName(select);
|
||||
}
|
||||
pub fn set_name(select: *parser.Select, name: []const u8) !void {
|
||||
return parser.selectSetName(select, name);
|
||||
}
|
||||
|
||||
pub fn get_disabled(select: *parser.Select) !bool {
|
||||
return parser.selectGetDisabled(select);
|
||||
}
|
||||
pub fn set_disabled(select: *parser.Select, disabled: bool) !void {
|
||||
return parser.selectSetDisabled(select, disabled);
|
||||
}
|
||||
|
||||
pub fn get_multiple(select: *parser.Select) !bool {
|
||||
return parser.selectGetMultiple(select);
|
||||
}
|
||||
pub fn set_multiple(select: *parser.Select, multiple: bool) !void {
|
||||
return parser.selectSetMultiple(select, multiple);
|
||||
}
|
||||
|
||||
pub fn get_selectedIndex(select: *parser.Select, state: *SessionState) !i32 {
|
||||
const self = try state.getOrCreateNodeWrapper(HTMLSelectElement, @ptrCast(select));
|
||||
const selected_index = try parser.selectGetSelectedIndex(select);
|
||||
|
||||
// See the explicit_index_set field documentation
|
||||
if (!self.explicit_index_set) {
|
||||
if (selected_index == -1) {
|
||||
if (try parser.selectGetMultiple(select) == false) {
|
||||
if (try get_length(select) > 0) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected_index;
|
||||
}
|
||||
|
||||
// Libdom's dom_html_select_select_set_selected_index will crash if index
|
||||
// is out of range, and it doesn't properly unset options
|
||||
pub fn set_selectedIndex(select: *parser.Select, index: i32, state: *SessionState) !void {
|
||||
var self = try state.getOrCreateNodeWrapper(HTMLSelectElement, @ptrCast(select));
|
||||
self.explicit_index_set = true;
|
||||
|
||||
const options = try parser.selectGetOptions(select);
|
||||
const len = try parser.optionCollectionGetLength(options);
|
||||
for (0..len) |i| {
|
||||
const option = try parser.optionCollectionItem(options, @intCast(i));
|
||||
try parser.optionSetSelected(option, false);
|
||||
}
|
||||
if (index >= 0 and index < try get_length(select)) {
|
||||
const option = try parser.optionCollectionItem(options, @intCast(index));
|
||||
try parser.optionSetSelected(option, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Select" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
|
||||
\\ <form id=f1>
|
||||
\\ <select id=s1 name=s1><option>o1<option>o2</select>
|
||||
\\ </form>
|
||||
\\ <select id=s2></select>
|
||||
});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const s = document.getElementById('s1');", null },
|
||||
.{ "s.form", "[object HTMLFormElement]" },
|
||||
|
||||
.{ "document.getElementById('s2').form", "null" },
|
||||
|
||||
.{ "s.disabled", "false" },
|
||||
.{ "s.disabled = true", null },
|
||||
.{ "s.disabled", "true" },
|
||||
.{ "s.disabled = false", null },
|
||||
.{ "s.disabled", "false" },
|
||||
|
||||
.{ "s.multiple", "false" },
|
||||
.{ "s.multiple = true", null },
|
||||
.{ "s.multiple", "true" },
|
||||
.{ "s.multiple = false", null },
|
||||
.{ "s.multiple", "false" },
|
||||
|
||||
.{ "s.name;", "s1" },
|
||||
.{ "s.name = 'sel1';", null },
|
||||
.{ "s.name", "sel1" },
|
||||
|
||||
.{ "s.length;", "2" },
|
||||
|
||||
.{ "s.selectedIndex", "0" },
|
||||
.{ "s.selectedIndex = 2", null }, // out of range
|
||||
.{ "s.selectedIndex", "-1" },
|
||||
|
||||
.{ "s.selectedIndex = -1", null },
|
||||
.{ "s.selectedIndex", "-1" },
|
||||
|
||||
.{ "s.selectedIndex = 0", null },
|
||||
.{ "s.selectedIndex", "0" },
|
||||
|
||||
.{ "s.selectedIndex = 1", null },
|
||||
.{ "s.selectedIndex", "1" },
|
||||
|
||||
.{ "s.selectedIndex = -323", null },
|
||||
.{ "s.selectedIndex", "-1" },
|
||||
}, .{});
|
||||
}
|
||||
149
src/browser/html/trusted_types.zig
Normal file
149
src/browser/html/trusted_types.zig
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
const log = std.log.scoped(.trusted_types);
|
||||
|
||||
pub const Interfaces = .{
|
||||
TrustedTypePolicyFactory,
|
||||
TrustedTypePolicy,
|
||||
TrustedTypePolicyOptions,
|
||||
TrustedHTML,
|
||||
TrustedScript,
|
||||
TrustedScriptURL,
|
||||
};
|
||||
|
||||
const TrustedHTML = struct {
|
||||
value: []const u8,
|
||||
|
||||
// TODO _toJSON
|
||||
pub fn _toString(self: *const TrustedHTML) []const u8 {
|
||||
return self.value;
|
||||
}
|
||||
};
|
||||
const TrustedScript = struct {
|
||||
value: []const u8,
|
||||
|
||||
pub fn _toString(self: *const TrustedScript) []const u8 {
|
||||
return self.value;
|
||||
}
|
||||
};
|
||||
const TrustedScriptURL = struct {
|
||||
value: []const u8,
|
||||
|
||||
pub fn _toString(self: *const TrustedScriptURL) []const u8 {
|
||||
return self.value;
|
||||
}
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/TrustedTypePolicyFactory
|
||||
pub const TrustedTypePolicyFactory = struct {
|
||||
// TBD innerHTML if set the default createHTML should be used when `element.innerHTML = userInput;` does v8 do that for us? Prob not.
|
||||
default_policy: ?TrustedTypePolicy = null, // The default policy, set by creating a policy with the name "default".
|
||||
created_policy_names: std.ArrayListUnmanaged([]const u8) = .empty,
|
||||
|
||||
pub fn _defaultPolicy(self: *TrustedTypePolicyFactory) ?TrustedTypePolicy {
|
||||
return self.default_policy;
|
||||
}
|
||||
|
||||
// https://w3c.github.io/trusted-types/dist/spec/#dom-trustedtypepolicyfactory-createpolicy
|
||||
// https://w3c.github.io/trusted-types/dist/spec/#abstract-opdef-create-a-trusted-type-policy
|
||||
pub fn _createPolicy(self: *TrustedTypePolicyFactory, name: []const u8, options: ?TrustedTypePolicyOptions, state: *SessionState) !TrustedTypePolicy {
|
||||
// TODO Throw TypeError if policy names are restricted by the Content Security Policy trusted-types directive and this name is not on the allowlist.
|
||||
// TODO Throw TypeError if the name is a duplicate and the Content Security Policy trusted-types directive is not using allow-duplicates
|
||||
|
||||
const policy = TrustedTypePolicy{
|
||||
.name = name,
|
||||
.options = options orelse TrustedTypePolicyOptions{},
|
||||
};
|
||||
|
||||
if (std.mem.eql(u8, name, "default")) {
|
||||
// TBD what if default_policy is already set?
|
||||
self.default_policy = policy;
|
||||
}
|
||||
try self.created_policy_names.append(state.arena, try state.arena.dupe(u8, name));
|
||||
|
||||
return policy;
|
||||
}
|
||||
};
|
||||
|
||||
pub const TrustedTypePolicyOptions = struct {
|
||||
createHTML: ?Env.Function = null, // (str, ..args) -> str
|
||||
createScript: ?Env.Function = null, // (str, ..args) -> str
|
||||
createScriptURL: ?Env.Function = null, // (str, ..args) -> str
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/TrustedTypePolicy
|
||||
pub const TrustedTypePolicy = struct {
|
||||
name: []const u8,
|
||||
options: TrustedTypePolicyOptions,
|
||||
|
||||
pub fn get_name(self: *TrustedTypePolicy) []const u8 {
|
||||
return self.name;
|
||||
}
|
||||
|
||||
pub fn _createHTML(self: *TrustedTypePolicy, html: []const u8) !TrustedHTML {
|
||||
// TODO handle throwIfMissing
|
||||
const create = self.options.createHTML orelse return error.TypeError;
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
const out = try create.tryCall([]const u8, .{html}, &result); // TODO varargs
|
||||
return .{
|
||||
.value = out,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _createScript(self: *TrustedTypePolicy, script: []const u8) !TrustedScript {
|
||||
// TODO handle throwIfMissing
|
||||
const create = self.options.createScript orelse return error.TypeError;
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
return try create.tryCall(TrustedScript, .{script}, &result); // TODO varargs
|
||||
}
|
||||
|
||||
pub fn _createScriptURL(self: *TrustedTypePolicy, url: []const u8) !TrustedScriptURL {
|
||||
// TODO handle throwIfMissing
|
||||
const create = self.options.createScriptURL orelse return error.TypeError;
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
return try create.tryCall(TrustedScriptURL, .{url}, &result); // TODO varargs
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.TrustedTypes" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "trustedTypes", "[object TrustedTypePolicyFactory]" },
|
||||
.{
|
||||
\\ let escapeHTMLPolicy = trustedTypes.createPolicy('myEscapePolicy', {
|
||||
\\ createHTML: (string) => string.replace(/</g, "<"),
|
||||
\\ });
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "escapeHTMLPolicy.createHTML('<img src=x onerror=alert(1)>');", "<img src=x onerror=alert(1)>" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Callback = @import("../env.zig").Callback;
|
||||
const Function = @import("../env.zig").Function;
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
|
||||
@@ -29,6 +29,9 @@ const Location = @import("location.zig").Location;
|
||||
const Crypto = @import("../crypto/crypto.zig").Crypto;
|
||||
const Console = @import("../console/console.zig").Console;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
||||
const Performance = @import("performance.zig").Performance;
|
||||
const TrustedTypePolicyFactory = @import("trusted_types.zig").TrustedTypePolicyFactory;
|
||||
|
||||
const storage = @import("../storage/storage.zig");
|
||||
|
||||
@@ -42,7 +45,7 @@ pub const Window = struct {
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
document: ?*parser.DocumentHTML = null,
|
||||
document: *parser.DocumentHTML,
|
||||
target: []const u8 = "",
|
||||
history: History = .{},
|
||||
location: Location = .{},
|
||||
@@ -55,22 +58,30 @@ pub const Window = struct {
|
||||
crypto: Crypto = .{},
|
||||
console: Console = .{},
|
||||
navigator: Navigator = .{},
|
||||
performance: Performance,
|
||||
trusted_types: TrustedTypePolicyFactory = .{},
|
||||
|
||||
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
|
||||
var fbs = std.io.fixedBufferStream("");
|
||||
const html_doc = try parser.documentHTMLParse(fbs.reader(), "utf-8");
|
||||
const doc = parser.documentHTMLToDocument(html_doc);
|
||||
try parser.documentSetDocumentURI(doc, "about:blank");
|
||||
|
||||
pub fn create(target: ?[]const u8, navigator: ?Navigator) Window {
|
||||
return .{
|
||||
.document = html_doc,
|
||||
.target = target orelse "",
|
||||
.navigator = navigator orelse .{},
|
||||
.performance = .{ .time_origin = try std.time.Timer.start() },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn replaceLocation(self: *Window, loc: Location) !void {
|
||||
self.location = loc;
|
||||
if (self.document) |doc| {
|
||||
try parser.documentHTMLSetLocation(Location, doc, &self.location);
|
||||
}
|
||||
try parser.documentHTMLSetLocation(Location, self.document, &self.location);
|
||||
}
|
||||
|
||||
pub fn replaceDocument(self: *Window, doc: *parser.DocumentHTML) !void {
|
||||
self.performance.time_origin.reset(); // When to reset see: https://developer.mozilla.org/en-US/docs/Web/API/Performance/timeOrigin
|
||||
self.document = doc;
|
||||
try parser.documentHTMLSetLocation(Location, doc, &self.location);
|
||||
}
|
||||
@@ -115,6 +126,18 @@ pub const Window = struct {
|
||||
return &self.history;
|
||||
}
|
||||
|
||||
// The interior height of the window in pixels, including the height of the horizontal scroll bar, if present.
|
||||
pub fn get_innerHeight(_: *Window, state: *SessionState) u32 {
|
||||
// We do not have scrollbars or padding so this is the same as Element.clientHeight
|
||||
return state.renderer.height();
|
||||
}
|
||||
|
||||
// The interior width of the window in pixels. That includes the width of the vertical scroll bar, if one is present.
|
||||
pub fn get_innerWidth(_: *Window, state: *SessionState) u32 {
|
||||
// We do not have scrollbars or padding so this is the same as Element.clientWidth
|
||||
return state.renderer.width();
|
||||
}
|
||||
|
||||
pub fn get_name(self: *Window) []const u8 {
|
||||
return self.target;
|
||||
}
|
||||
@@ -129,13 +152,44 @@ pub const Window = struct {
|
||||
return &self.storage_shelf.?.bucket.session;
|
||||
}
|
||||
|
||||
pub fn get_performance(self: *Window) *Performance {
|
||||
return &self.performance;
|
||||
}
|
||||
|
||||
pub fn get_trustedTypes(self: *Window) !TrustedTypePolicyFactory {
|
||||
return self.trusted_types;
|
||||
}
|
||||
|
||||
// Tells the browser you wish to perform an animation. It requests the browser to call a user-supplied callback function before the next repaint.
|
||||
// fn callback(timestamp: f64)
|
||||
// Returns the request ID, that uniquely identifies the entry in the callback list.
|
||||
pub fn _requestAnimationFrame(
|
||||
self: *Window,
|
||||
callback: Function,
|
||||
) !u32 {
|
||||
// We immediately execute the callback, but this may not be correct TBD.
|
||||
// Since: When multiple callbacks queued by requestAnimationFrame() begin to fire in a single frame, each receives the same timestamp even though time has passed during the computation of every previous callback's workload.
|
||||
var result: Function.Result = undefined;
|
||||
callback.tryCall(void, .{self.performance._now()}, &result) catch {
|
||||
log.err("Window.requestAnimationFrame(): {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
return 99; // not unique, but user cannot make assumptions about it. cancelAnimationFrame will be too late anyway.
|
||||
}
|
||||
|
||||
// Cancels an animation frame request previously scheduled through requestAnimationFrame().
|
||||
// This is a no-op since _requestAnimationFrame immediately executes the callback.
|
||||
pub fn _cancelAnimationFrame(_: *Window, request_id: u32) void {
|
||||
_ = request_id;
|
||||
}
|
||||
|
||||
// TODO handle callback arguments.
|
||||
pub fn _setTimeout(self: *Window, cbk: Callback, delay: ?u32, state: *SessionState) !u32 {
|
||||
pub fn _setTimeout(self: *Window, cbk: Function, delay: ?u32, state: *SessionState) !u32 {
|
||||
return self.createTimeout(cbk, delay, state, false);
|
||||
}
|
||||
|
||||
// TODO handle callback arguments.
|
||||
pub fn _setInterval(self: *Window, cbk: Callback, delay: ?u32, state: *SessionState) !u32 {
|
||||
pub fn _setInterval(self: *Window, cbk: Function, delay: ?u32, state: *SessionState) !u32 {
|
||||
return self.createTimeout(cbk, delay, state, true);
|
||||
}
|
||||
|
||||
@@ -149,7 +203,14 @@ pub const Window = struct {
|
||||
try state.loop.cancel(kv.value.loop_id);
|
||||
}
|
||||
|
||||
pub fn createTimeout(self: *Window, cbk: Callback, delay_: ?u32, state: *SessionState, comptime repeat: bool) !u32 {
|
||||
pub fn _matchMedia(_: *const Window, media: []const u8, state: *SessionState) !MediaQueryList {
|
||||
return .{
|
||||
.matches = false, // TODO?
|
||||
.media = try state.arena.dupe(u8, media),
|
||||
};
|
||||
}
|
||||
|
||||
fn createTimeout(self: *Window, cbk: Function, delay_: ?u32, state: *SessionState, comptime repeat: bool) !u32 {
|
||||
if (self.timers.count() > 512) {
|
||||
return error.TooManyTimeout;
|
||||
}
|
||||
@@ -181,6 +242,15 @@ pub const Window = struct {
|
||||
gop.value_ptr.* = callback;
|
||||
return timer_id;
|
||||
}
|
||||
|
||||
// NOT IMPLEMENTED - This is a dummy implementation that always returns null to deter PlayWright from using this path to solve click.js.
|
||||
// returns an object containing the values of all CSS properties of an element, after applying active stylesheets and resolving any basic computation those values may contain.
|
||||
pub fn _getComputedStyle(_: *Window, element: *parser.Element, pseudo_element: ?[]const u8) !?void {
|
||||
_ = element;
|
||||
_ = pseudo_element;
|
||||
log.warn("Not implemented function getComputedStyle called, null returned", .{});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const TimerCallback = struct {
|
||||
@@ -191,7 +261,7 @@ const TimerCallback = struct {
|
||||
timer_id: u31,
|
||||
|
||||
// The JavaScript callback to execute
|
||||
cbk: Callback,
|
||||
cbk: Function,
|
||||
|
||||
// This is the internal data that the event loop tracks. We'll get this
|
||||
// back in run and, from it, can get our TimerCallback instance
|
||||
@@ -205,8 +275,8 @@ const TimerCallback = struct {
|
||||
fn run(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
const self: *TimerCallback = @fieldParentPtr("node", node);
|
||||
|
||||
var result: Callback.Result = undefined;
|
||||
self.cbk.tryCall(.{}, &result) catch {
|
||||
var result: Function.Result = undefined;
|
||||
self.cbk.tryCall(void, .{}, &result) catch {
|
||||
log.err("timeout callback error: {s}", .{result.exception});
|
||||
log.debug("stack:\n{s}", .{result.stack orelse "???"});
|
||||
};
|
||||
@@ -221,3 +291,56 @@ const TimerCallback = struct {
|
||||
_ = self.window.timers.remove(self.timer_id);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Window" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
// requestAnimationFrame should be able to wait by recursively calling itself
|
||||
// Note however that we in this test do not wait as the request is just send to the browser
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ let start;
|
||||
\\ function step(timestamp) {
|
||||
\\ if (start === undefined) {
|
||||
\\ start = timestamp;
|
||||
\\ }
|
||||
\\ const elapsed = timestamp - start;
|
||||
\\ if (elapsed < 2000) {
|
||||
\\ requestAnimationFrame(step);
|
||||
\\ }
|
||||
\\ }
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
|
||||
}, .{});
|
||||
|
||||
// cancelAnimationFrame should be able to cancel a request with the given id
|
||||
try runner.testCases(&.{
|
||||
.{ "let request_id = requestAnimationFrame(timestamp => {});", null },
|
||||
.{ "cancelAnimationFrame(request_id);", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "innerHeight", "1" },
|
||||
.{ "innerWidth", "1" }, // Width is 1 even if there are no elements
|
||||
.{
|
||||
\\ let div1 = document.createElement('div');
|
||||
\\ document.body.appendChild(div1);
|
||||
\\ div1.getClientRects();
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{
|
||||
\\ let div2 = document.createElement('div');
|
||||
\\ document.body.appendChild(div2);
|
||||
\\ div2.getClientRects();
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "innerHeight", "1" },
|
||||
.{ "innerWidth", "2" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const c = @cImport({
|
||||
@cInclude("events/event_target.h");
|
||||
@cInclude("events/event.h");
|
||||
@cInclude("events/mouse_event.h");
|
||||
@cInclude("utils/validate.h");
|
||||
});
|
||||
|
||||
const mimalloc = @import("mimalloc.zig");
|
||||
@@ -1150,6 +1151,17 @@ pub fn nodeGetChildNodes(node: *Node) !*NodeList {
|
||||
return nlist.?;
|
||||
}
|
||||
|
||||
pub fn nodeGetRootNode(node: *Node) !*Node {
|
||||
var root = node;
|
||||
while (true) {
|
||||
const parent = try nodeParentNode(root);
|
||||
if (parent) |parent_| {
|
||||
root = parent_;
|
||||
} else break;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
pub fn nodeAppendChild(node: *Node, child: *Node) !*Node {
|
||||
var res: ?*Node = undefined;
|
||||
const err = nodeVtable(node).dom_node_append_child.?(node, child, &res);
|
||||
@@ -1273,6 +1285,14 @@ pub fn nodeGetPrefix(node: *Node) !?[]const u8 {
|
||||
return strToData(s.?);
|
||||
}
|
||||
|
||||
pub fn nodeGetEmbedderData(node: *Node) ?*anyopaque {
|
||||
return c._dom_node_get_embedder_data(node);
|
||||
}
|
||||
|
||||
pub fn nodeSetEmbedderData(node: *Node, data: *anyopaque) void {
|
||||
c._dom_node_set_embedder_data(node, data);
|
||||
}
|
||||
|
||||
// nodeToElement is an helper to convert a node to an element.
|
||||
pub inline fn nodeToElement(node: *Node) *Element {
|
||||
return @as(*Element, @ptrCast(node));
|
||||
@@ -1283,6 +1303,15 @@ pub inline fn nodeToDocument(node: *Node) *Document {
|
||||
return @as(*Document, @ptrCast(node));
|
||||
}
|
||||
|
||||
// Combination of nodeToElement + elementHTMLGetTagType
|
||||
pub fn nodeHTMLGetTagType(node: *Node) !?Tag {
|
||||
if (try nodeType(node) != .element) {
|
||||
return null;
|
||||
}
|
||||
const html_element: *ElementHTML = @ptrCast(node);
|
||||
return try elementHTMLGetTagType(html_element);
|
||||
}
|
||||
|
||||
// CharacterData
|
||||
pub const CharacterData = c.dom_characterdata;
|
||||
|
||||
@@ -1509,6 +1538,13 @@ pub fn elementHasAttribute(elem: *Element, qname: []const u8) !bool {
|
||||
return res;
|
||||
}
|
||||
|
||||
pub fn elementHasAttributeNS(elem: *Element, ns: []const u8, qname: []const u8) !bool {
|
||||
var res: bool = undefined;
|
||||
const err = elementVtable(elem).dom_element_has_attribute_ns.?(elem, if (ns.len == 0) null else try strFromData(ns), try strFromData(qname), &res);
|
||||
try DOMErr(err);
|
||||
return res;
|
||||
}
|
||||
|
||||
pub fn elementGetAttributeNode(elem: *Element, name: []const u8) !?*Attribute {
|
||||
var a: ?*Attribute = undefined;
|
||||
const err = elementVtable(elem).dom_element_get_attribute_node.?(elem, try strFromData(name), &a);
|
||||
@@ -1520,7 +1556,7 @@ pub fn elementGetAttributeNodeNS(elem: *Element, ns: []const u8, name: []const u
|
||||
var a: ?*Attribute = undefined;
|
||||
const err = elementVtable(elem).dom_element_get_attribute_node_ns.?(
|
||||
elem,
|
||||
try strFromData(ns),
|
||||
if (ns.len == 0) null else try strFromData(ns),
|
||||
try strFromData(name),
|
||||
&a,
|
||||
);
|
||||
@@ -1791,6 +1827,8 @@ pub const Title = c.dom_html_title_element;
|
||||
pub const Track = struct { base: *c.dom_html_element };
|
||||
pub const UList = c.dom_html_u_list_element;
|
||||
pub const Video = struct { base: *c.dom_html_element };
|
||||
pub const HTMLCollection = c.dom_html_collection;
|
||||
pub const OptionCollection = c.dom_html_options_collection;
|
||||
|
||||
// Document Fragment
|
||||
pub const DocumentFragment = c.dom_document_fragment;
|
||||
@@ -1904,7 +1942,6 @@ pub inline fn domImplementationCreateHTMLDocument(title: ?[]const u8) !*Document
|
||||
_ = try nodeAppendChild(elementToNode(html), elementToNode(head));
|
||||
|
||||
if (title) |t| {
|
||||
try documentHTMLSetTitle(doc_html, t);
|
||||
const htitle = try documentCreateElement(doc, "title");
|
||||
const txt = try documentCreateTextNode(doc, t);
|
||||
_ = try nodeAppendChild(elementToNode(htitle), @as(*Node, @ptrCast(txt)));
|
||||
@@ -2242,6 +2279,10 @@ pub inline fn documentHTMLBody(doc_html: *DocumentHTML) !?*Body {
|
||||
return @as(*Body, @ptrCast(body.?));
|
||||
}
|
||||
|
||||
pub inline fn bodyToElement(body: *Body) *Element {
|
||||
return @as(*Element, @ptrCast(body));
|
||||
}
|
||||
|
||||
pub inline fn documentHTMLSetBody(doc_html: *DocumentHTML, elt: ?*ElementHTML) !void {
|
||||
const err = documentHTMLVtable(doc_html).set_body.?(doc_html, elt);
|
||||
try DOMErr(err);
|
||||
@@ -2307,3 +2348,245 @@ pub fn documentHTMLGetLocation(T: type, doc: *DocumentHTML) !?*T {
|
||||
const ptr: *align(@alignOf(*T)) anyopaque = @alignCast(l.?);
|
||||
return @as(*T, @ptrCast(ptr));
|
||||
}
|
||||
|
||||
pub fn validateName(name: []const u8) !bool {
|
||||
return c._dom_validate_name(try strFromData(name));
|
||||
}
|
||||
|
||||
// Form
|
||||
pub fn formElementSubmit(form: *Form) !void {
|
||||
const err = c.dom_html_form_element_submit(form);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn formElementReset(form: *Form) !void {
|
||||
const err = c.dom_html_form_element_reset(form);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn formGetCollection(form: *Form) !*HTMLCollection {
|
||||
var collection: ?*HTMLCollection = null;
|
||||
const err = c.dom_html_form_element_get_elements(form, &collection);
|
||||
try DOMErr(err);
|
||||
return collection.?;
|
||||
}
|
||||
|
||||
// TextArea
|
||||
pub fn textareaGetValue(textarea: *TextArea) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_text_area_element_get_value(textarea, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
|
||||
// Select
|
||||
pub fn selectGetOptions(select: *Select) !*OptionCollection {
|
||||
var collection: ?*OptionCollection = null;
|
||||
const err = c.dom__html_select_element_get_options(select, &collection);
|
||||
try DOMErr(err);
|
||||
return collection.?;
|
||||
}
|
||||
|
||||
pub fn selectGetDisabled(select: *Select) !bool {
|
||||
var disabled: bool = false;
|
||||
const err = c.dom_html_select_element_get_disabled(select, &disabled);
|
||||
try DOMErr(err);
|
||||
return disabled;
|
||||
}
|
||||
|
||||
pub fn selectSetDisabled(select: *Select, disabled: bool) !void {
|
||||
const err = c.dom_html_select_element_set_disabled(select, disabled);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn selectGetMultiple(select: *Select) !bool {
|
||||
var multiple: bool = false;
|
||||
const err = c.dom_html_select_element_get_multiple(select, &multiple);
|
||||
try DOMErr(err);
|
||||
return multiple;
|
||||
}
|
||||
|
||||
pub fn selectSetMultiple(select: *Select, multiple: bool) !void {
|
||||
const err = c.dom_html_select_element_set_multiple(select, multiple);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn selectGetName(select: *Select) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_select_element_get_name(select, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
|
||||
pub fn selectSetName(select: *Select, name: []const u8) !void {
|
||||
const err = c.dom_html_select_element_set_name(select, try strFromData(name));
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn selectGetLength(select: *Select) !u32 {
|
||||
var length: u32 = 0;
|
||||
const err = c.dom_html_select_element_get_length(select, &length);
|
||||
try DOMErr(err);
|
||||
return length;
|
||||
}
|
||||
|
||||
pub fn selectGetSelectedIndex(select: *Select) !i32 {
|
||||
var index: i32 = 0;
|
||||
const err = c.dom_html_select_element_get_selected_index(select, &index);
|
||||
try DOMErr(err);
|
||||
return index;
|
||||
}
|
||||
|
||||
pub fn selectSetSelectedIndex(select: *Select, index: i32) !void {
|
||||
const err = c.dom_html_select_element_set_selected_index(select, index);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn selectGetForm(select: *Select) !?*Form {
|
||||
var form: ?*Form = null;
|
||||
const err = c.dom_html_select_element_get_form(select, &form);
|
||||
try DOMErr(err);
|
||||
return form;
|
||||
}
|
||||
|
||||
// OptionCollection
|
||||
pub fn optionCollectionGetLength(collection: *OptionCollection) !u32 {
|
||||
var len: u32 = 0;
|
||||
const err = c.dom_html_options_collection_get_length(collection, &len);
|
||||
try DOMErr(err);
|
||||
return len;
|
||||
}
|
||||
|
||||
pub fn optionCollectionItem(collection: *OptionCollection, index: u32) !*Option {
|
||||
var node: ?*NodeExternal = undefined;
|
||||
const err = c.dom_html_options_collection_item(collection, index, &node);
|
||||
try DOMErr(err);
|
||||
return @ptrCast(node.?);
|
||||
}
|
||||
|
||||
// Option
|
||||
pub fn optionGetValue(option: *Option) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_option_element_get_value(option, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
|
||||
pub fn optionGetSelected(option: *Option) !bool {
|
||||
var selected: bool = false;
|
||||
const err = c.dom_html_option_element_get_selected(option, &selected);
|
||||
try DOMErr(err);
|
||||
return selected;
|
||||
}
|
||||
|
||||
pub fn optionSetSelected(option: *Option, selected: bool) !void {
|
||||
const err = c.dom_html_option_element_set_selected(option, selected);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
// Input
|
||||
pub fn inputGetChecked(input: *Input) !bool {
|
||||
var b: bool = false;
|
||||
const err = c.dom_html_input_element_get_checked(input, &b);
|
||||
try DOMErr(err);
|
||||
return b;
|
||||
}
|
||||
|
||||
// HtmlCollection
|
||||
pub fn htmlCollectionGetLength(collection: *HTMLCollection) !u32 {
|
||||
var len: u32 = 0;
|
||||
const err = c.dom_html_collection_get_length(collection, &len);
|
||||
try DOMErr(err);
|
||||
return len;
|
||||
}
|
||||
|
||||
pub fn htmlCollectionItem(collection: *HTMLCollection, index: u32) !*Node {
|
||||
var node: ?*NodeExternal = undefined;
|
||||
const err = c.dom_html_collection_item(collection, index, &node);
|
||||
try DOMErr(err);
|
||||
return @ptrCast(node.?);
|
||||
}
|
||||
|
||||
const ulongNegativeOne = 4294967295;
|
||||
|
||||
// Image
|
||||
// Image.name is deprecated
|
||||
// Image.align is deprecated
|
||||
// Image.border is deprecated
|
||||
// Image.longDesc is deprecated
|
||||
// Image.hspace is deprecated
|
||||
// Image.vspace is deprecated
|
||||
|
||||
pub fn imageGetAlt(image: *Image) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_image_element_get_alt(image, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
pub fn imageSetAlt(image: *Image, alt: []const u8) !void {
|
||||
const err = c.dom_html_image_element_set_alt(image, try strFromData(alt));
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn imageGetSrc(image: *Image) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_image_element_get_src(image, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
pub fn imageSetSrc(image: *Image, src: []const u8) !void {
|
||||
const err = c.dom_html_image_element_set_src(image, try strFromData(src));
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn imageGetUseMap(image: *Image) ![]const u8 {
|
||||
var s_: ?*String = null;
|
||||
const err = c.dom_html_image_element_get_use_map(image, &s_);
|
||||
try DOMErr(err);
|
||||
const s = s_ orelse return "";
|
||||
return strToData(s);
|
||||
}
|
||||
pub fn imageSetUseMap(image: *Image, use_map: []const u8) !void {
|
||||
const err = c.dom_html_image_element_set_use_map(image, try strFromData(use_map));
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn imageGetHeight(image: *Image) !u32 {
|
||||
var height: u32 = 0;
|
||||
const err = c.dom_html_image_element_get_height(image, &height);
|
||||
try DOMErr(err);
|
||||
if (height == ulongNegativeOne) return 0;
|
||||
return height;
|
||||
}
|
||||
pub fn imageSetHeight(image: *Image, height: u32) !void {
|
||||
const err = c.dom_html_image_element_set_height(image, height);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn imageGetWidth(image: *Image) !u32 {
|
||||
var width: u32 = 0;
|
||||
const err = c.dom_html_image_element_get_width(image, &width);
|
||||
try DOMErr(err);
|
||||
if (width == ulongNegativeOne) return 0;
|
||||
return width;
|
||||
}
|
||||
pub fn imageSetWidth(image: *Image, width: u32) !void {
|
||||
const err = c.dom_html_image_element_set_width(image, width);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
pub fn imageGetIsMap(image: *Image) !bool {
|
||||
var is_map: bool = false;
|
||||
const err = c.dom_html_image_element_get_is_map(image, &is_map);
|
||||
try DOMErr(err);
|
||||
return is_map;
|
||||
}
|
||||
pub fn imageSetIsMap(image: *Image, is_map: bool) !void {
|
||||
const err = c.dom_html_image_element_set_is_map(image, is_map);
|
||||
try DOMErr(err);
|
||||
}
|
||||
|
||||
676
src/browser/page.zig
Normal file
676
src/browser/page.zig
Normal file
@@ -0,0 +1,676 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Dump = @import("dump.zig");
|
||||
const Mime = @import("mime.zig").Mime;
|
||||
const DataURI = @import("datauri.zig").DataURI;
|
||||
const Session = @import("session.zig").Session;
|
||||
const Renderer = @import("renderer.zig").Renderer;
|
||||
const SessionState = @import("env.zig").SessionState;
|
||||
const Window = @import("html/window.zig").Window;
|
||||
const Walker = @import("dom/walker.zig").WalkerDepthFirst;
|
||||
const Env = @import("env.zig").Env;
|
||||
const Loop = @import("../runtime/loop.zig").Loop;
|
||||
const HTMLDocument = @import("html/document.zig").HTMLDocument;
|
||||
|
||||
const URL = @import("../url.zig").URL;
|
||||
|
||||
const parser = @import("netsurf.zig");
|
||||
const http = @import("../http/client.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
|
||||
const polyfill = @import("polyfill/polyfill.zig");
|
||||
|
||||
const log = std.log.scoped(.page);
|
||||
|
||||
// Page navigates to an url.
|
||||
// You can navigates multiple urls with the same page, but you have to call
|
||||
// end() to stop the previous navigation before starting a new one.
|
||||
// The page handle all its memory in an arena allocator. The arena is reseted
|
||||
// when end() is called.
|
||||
pub const Page = struct {
|
||||
session: *Session,
|
||||
|
||||
// an arena with a lifetime for the entire duration of the page
|
||||
arena: Allocator,
|
||||
|
||||
// Gets injected into any WebAPI method that needs it
|
||||
state: SessionState,
|
||||
|
||||
// Serves are the root object of our JavaScript environment
|
||||
window: Window,
|
||||
|
||||
// The URL of the page
|
||||
url: URL,
|
||||
|
||||
raw_data: ?[]const u8,
|
||||
|
||||
renderer: Renderer,
|
||||
|
||||
microtask_node: Loop.CallbackNode,
|
||||
|
||||
window_clicked_event_node: parser.EventNode,
|
||||
|
||||
scope: *Env.Scope,
|
||||
|
||||
// List of modules currently fetched/loaded.
|
||||
module_map: std.StringHashMapUnmanaged([]const u8),
|
||||
|
||||
// current_script is the script currently evaluated by the page.
|
||||
// current_script could by fetch module to resolve module's url to fetch.
|
||||
current_script: ?*const Script = null,
|
||||
|
||||
pub fn init(self: *Page, arena: Allocator, session: *Session) !void {
|
||||
const browser = session.browser;
|
||||
self.* = .{
|
||||
.window = try Window.create(null, null),
|
||||
.arena = arena,
|
||||
.raw_data = null,
|
||||
.url = URL.empty,
|
||||
.session = session,
|
||||
.renderer = Renderer.init(arena),
|
||||
.microtask_node = .{ .func = microtaskCallback },
|
||||
.window_clicked_event_node = .{ .func = windowClicked },
|
||||
.state = .{
|
||||
.arena = arena,
|
||||
.url = &self.url,
|
||||
.window = &self.window,
|
||||
.renderer = &self.renderer,
|
||||
.loop = browser.app.loop,
|
||||
.cookie_jar = &session.cookie_jar,
|
||||
.request_factory = browser.http_client.requestFactory(browser.notification),
|
||||
},
|
||||
.scope = try session.executor.startScope(&self.window, &self.state, self, true),
|
||||
.module_map = .empty,
|
||||
};
|
||||
|
||||
// load polyfills
|
||||
try polyfill.load(self.arena, self.scope);
|
||||
|
||||
// _ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
|
||||
}
|
||||
|
||||
fn microtaskCallback(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
const self: *Page = @fieldParentPtr("microtask_node", node);
|
||||
self.session.browser.runMicrotasks();
|
||||
repeat_delay.* = 1 * std.time.ns_per_ms;
|
||||
}
|
||||
|
||||
// dump writes the page content into the given file.
|
||||
pub fn dump(self: *const Page, out: std.fs.File) !void {
|
||||
if (self.raw_data) |raw_data| {
|
||||
// raw_data was set if the document was not HTML, dump the data content only.
|
||||
return try out.writeAll(raw_data);
|
||||
}
|
||||
|
||||
// if the page has a pointer to a document, dumps the HTML.
|
||||
const doc = parser.documentHTMLToDocument(self.window.document);
|
||||
try Dump.writeHTML(doc, out);
|
||||
}
|
||||
|
||||
pub fn fetchModuleSource(ctx: *anyopaque, specifier: []const u8) !?[]const u8 {
|
||||
const self: *Page = @ptrCast(@alignCast(ctx));
|
||||
|
||||
log.debug("fetch module: specifier: {s}", .{specifier});
|
||||
|
||||
const base = if (self.current_script) |s| s.src else null;
|
||||
|
||||
const file_src = blk: {
|
||||
if (base) |_base| {
|
||||
break :blk try URL.stitch(self.arena, specifier, _base);
|
||||
} else break :blk specifier;
|
||||
};
|
||||
|
||||
if (self.module_map.get(file_src)) |module| return module;
|
||||
|
||||
const module = try self.fetchData(specifier, base);
|
||||
if (module) |_module| try self.module_map.putNoClobber(self.arena, file_src, _module);
|
||||
return module;
|
||||
}
|
||||
|
||||
pub fn wait(self: *Page) !void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(self.scope);
|
||||
defer try_catch.deinit();
|
||||
|
||||
try self.session.browser.app.loop.run();
|
||||
|
||||
if (try_catch.hasCaught() == false) {
|
||||
log.debug("wait: OK", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = (try try_catch.err(self.arena)) orelse "unknown";
|
||||
log.info("wait error: {s}", .{msg});
|
||||
}
|
||||
|
||||
pub fn origin(self: *const Page, arena: Allocator) ![]const u8 {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
try self.url.origin(arr.writer(arena));
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
// spec reference: https://html.spec.whatwg.org/#document-lifecycle
|
||||
pub fn navigate(self: *Page, request_url: URL, opts: NavigateOpts) !void {
|
||||
const arena = self.arena;
|
||||
const session = self.session;
|
||||
const notification = session.browser.notification;
|
||||
|
||||
log.debug("starting GET {s}", .{request_url});
|
||||
|
||||
// if the url is about:blank, nothing to do.
|
||||
if (std.mem.eql(u8, "about:blank", request_url.raw)) {
|
||||
var fbs = std.io.fixedBufferStream("");
|
||||
try self.loadHTMLDoc(fbs.reader(), "utf-8");
|
||||
// We do not processHTMLDoc here as we know we don't have any scripts
|
||||
// This assumption may be false when CDP Page.addScriptToEvaluateOnNewDocument is implemented
|
||||
try HTMLDocument.documentIsComplete(self.window.document, &self.state);
|
||||
return;
|
||||
}
|
||||
|
||||
// we don't clone url, because we're going to replace self.url
|
||||
// later in this function, with the final request url (since we might
|
||||
// redirect)
|
||||
self.url = request_url;
|
||||
|
||||
// load the data
|
||||
var request = try self.newHTTPRequest(.GET, &self.url, .{ .navigation = true });
|
||||
defer request.deinit();
|
||||
request.notification = notification;
|
||||
|
||||
notification.dispatch(.page_navigate, &.{
|
||||
.opts = opts,
|
||||
.url = &self.url,
|
||||
.timestamp = timestamp(),
|
||||
});
|
||||
|
||||
var response = try request.sendSync(.{});
|
||||
|
||||
// would be different than self.url in the case of a redirect
|
||||
self.url = try URL.fromURI(arena, request.request_uri);
|
||||
|
||||
const header = response.header;
|
||||
try session.cookie_jar.populateFromResponse(&self.url.uri, &header);
|
||||
|
||||
// TODO handle fragment in url.
|
||||
try self.window.replaceLocation(.{ .url = try self.url.toWebApi(arena) });
|
||||
|
||||
log.info("GET {any} {d}", .{ self.url, header.status });
|
||||
|
||||
const content_type = header.get("content-type");
|
||||
|
||||
const mime: Mime = blk: {
|
||||
if (content_type) |ct| {
|
||||
break :blk try Mime.parse(arena, ct);
|
||||
}
|
||||
break :blk Mime.sniff(try response.peek());
|
||||
} orelse .unknown;
|
||||
|
||||
if (mime.isHTML()) {
|
||||
self.raw_data = null;
|
||||
try self.loadHTMLDoc(&response, mime.charset orelse "utf-8");
|
||||
try self.processHTMLDoc();
|
||||
} else {
|
||||
log.info("non-HTML document: {s}", .{content_type orelse "null"});
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
while (try response.next()) |data| {
|
||||
try arr.appendSlice(arena, try arena.dupe(u8, data));
|
||||
}
|
||||
// save the body into the page.
|
||||
self.raw_data = arr.items;
|
||||
}
|
||||
|
||||
notification.dispatch(.page_navigated, &.{
|
||||
.url = &self.url,
|
||||
.timestamp = timestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8) !void {
|
||||
log.debug("parse html with charset {s}", .{charset});
|
||||
|
||||
const ccharset = try self.arena.dupeZ(u8, charset);
|
||||
|
||||
const html_doc = try parser.documentHTMLParse(reader, ccharset);
|
||||
const doc = parser.documentHTMLToDocument(html_doc);
|
||||
|
||||
// inject the URL to the document including the fragment.
|
||||
try parser.documentSetDocumentURI(doc, self.url.raw);
|
||||
|
||||
// TODO set the referrer to the document.
|
||||
try self.window.replaceDocument(html_doc);
|
||||
self.window.setStorageShelf(
|
||||
try self.session.storage_shed.getOrPut(try self.origin(self.arena)),
|
||||
);
|
||||
}
|
||||
|
||||
fn processHTMLDoc(self: *Page) !void {
|
||||
const html_doc = self.window.document;
|
||||
const doc = parser.documentHTMLToDocument(html_doc);
|
||||
|
||||
const document_element = (try parser.documentGetDocumentElement(doc)) orelse return error.DocumentElementError;
|
||||
try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Element, document_element),
|
||||
"click",
|
||||
&self.window_clicked_event_node,
|
||||
false,
|
||||
);
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
|
||||
// browse the DOM tree to retrieve scripts
|
||||
// TODO execute the synchronous scripts during the HTL parsing.
|
||||
// TODO fetch the script resources concurrently but execute them in the
|
||||
// declaration order for synchronous ones.
|
||||
|
||||
// async_scripts stores scripts which can be run asynchronously.
|
||||
// for now they are just run after the non-async one in order to
|
||||
// dispatch DOMContentLoaded the sooner as possible.
|
||||
var async_scripts: std.ArrayListUnmanaged(Script) = .{};
|
||||
|
||||
// defer_scripts stores scripts which are meant to be deferred. For now
|
||||
// this doesn't have a huge impact, since normal scripts are parsed
|
||||
// after the document is loaded. But (a) we should fix that and (b)
|
||||
// this results in JavaScript being loaded in the same order as browsers
|
||||
// which can help debug issues (and might actually fix issues if websites
|
||||
// are expecting this execution order)
|
||||
var defer_scripts: std.ArrayListUnmanaged(Script) = .{};
|
||||
|
||||
const root = parser.documentToNode(doc);
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = try walker.get_next(root, next) orelse break;
|
||||
|
||||
// ignore non-elements nodes.
|
||||
if (try parser.nodeType(next.?) != .element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const e = parser.nodeToElement(next.?);
|
||||
|
||||
// ignore non-js script.
|
||||
const script = try Script.init(e) orelse continue;
|
||||
|
||||
// TODO use fetchpriority
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#fetchpriority
|
||||
|
||||
// > async
|
||||
// > For classic scripts, if the async attribute is present,
|
||||
// > then the classic script will be fetched in parallel to
|
||||
// > parsing and evaluated as soon as it is available.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#async
|
||||
if (script.is_async) {
|
||||
try async_scripts.append(self.arena, script);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (script.is_defer) {
|
||||
try defer_scripts.append(self.arena, script);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO handle for attribute
|
||||
// TODO handle event attribute
|
||||
|
||||
// > Scripts without async, defer or type="module"
|
||||
// > attributes, as well as inline scripts without the
|
||||
// > type="module" attribute, are fetched and executed
|
||||
// > immediately before the browser continues to parse the
|
||||
// > page.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#notes
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, @ptrCast(e));
|
||||
self.evalScript(&script) catch |err| log.warn("evaljs: {any}", .{err});
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, null);
|
||||
}
|
||||
|
||||
for (defer_scripts.items) |s| {
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, @ptrCast(s.element));
|
||||
self.evalScript(&s) catch |err| log.warn("evaljs: {any}", .{err});
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, null);
|
||||
}
|
||||
// dispatch DOMContentLoaded before the transition to "complete",
|
||||
// at the point where all subresources apart from async script elements
|
||||
// have loaded.
|
||||
// https://html.spec.whatwg.org/#reporting-document-loading-status
|
||||
try HTMLDocument.documentIsLoaded(html_doc, &self.state);
|
||||
|
||||
// eval async scripts.
|
||||
for (async_scripts.items) |s| {
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, @ptrCast(s.element));
|
||||
self.evalScript(&s) catch |err| log.warn("evaljs: {any}", .{err});
|
||||
try parser.documentHTMLSetCurrentScript(html_doc, null);
|
||||
}
|
||||
|
||||
try HTMLDocument.documentIsComplete(html_doc, &self.state);
|
||||
|
||||
// dispatch window.load event
|
||||
const loadevt = try parser.eventCreate();
|
||||
defer parser.eventDestroy(loadevt);
|
||||
|
||||
try parser.eventInit(loadevt, "load", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(Window, &self.window),
|
||||
loadevt,
|
||||
);
|
||||
}
|
||||
|
||||
// evalScript evaluates the src in priority.
|
||||
// if no src is present, we evaluate the text source.
|
||||
// https://html.spec.whatwg.org/multipage/scripting.html#script-processing-model
|
||||
fn evalScript(self: *Page, script: *const Script) !void {
|
||||
const src = script.src orelse {
|
||||
// source is inline
|
||||
// TODO handle charset attribute
|
||||
if (try parser.nodeTextContent(parser.elementToNode(script.element))) |text| {
|
||||
try script.eval(self, text);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
self.current_script = script;
|
||||
defer self.current_script = null;
|
||||
|
||||
log.debug("starting GET {s}", .{src});
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/webappapis.html#fetch-a-classic-script
|
||||
const body = (try self.fetchData(src, null)) orelse {
|
||||
// TODO If el's result is null, then fire an event named error at
|
||||
// el, and return
|
||||
return;
|
||||
};
|
||||
|
||||
script.eval(self, body) catch |err| switch (err) {
|
||||
error.JsErr => {}, // nothing to do here.
|
||||
else => return err,
|
||||
};
|
||||
|
||||
// TODO If el's from an external file is true, then fire an event
|
||||
// named load at el.
|
||||
}
|
||||
|
||||
// fetchData returns the data corresponding to the src target.
|
||||
// It resolves src using the page's uri.
|
||||
// If a base path is given, src is resolved according to the base first.
|
||||
// the caller owns the returned string
|
||||
fn fetchData(self: *const Page, src: []const u8, base: ?[]const u8) !?[]const u8 {
|
||||
log.debug("starting fetch {s}", .{src});
|
||||
|
||||
const arena = self.arena;
|
||||
|
||||
// Handle data URIs.
|
||||
if (try DataURI.parse(arena, src)) |data_uri| {
|
||||
return data_uri.data;
|
||||
}
|
||||
|
||||
var res_src = src;
|
||||
|
||||
// if a base path is given, we resolve src using base.
|
||||
if (base) |_base| {
|
||||
res_src = try URL.stitch(arena, src, _base);
|
||||
}
|
||||
|
||||
var origin_url = &self.url;
|
||||
const url = try origin_url.resolve(arena, res_src);
|
||||
|
||||
var request = try self.newHTTPRequest(.GET, &url, .{
|
||||
.origin_uri = &origin_url.uri,
|
||||
.navigation = false,
|
||||
});
|
||||
defer request.deinit();
|
||||
|
||||
var response = try request.sendSync(.{});
|
||||
var header = response.header;
|
||||
try self.session.cookie_jar.populateFromResponse(&url.uri, &header);
|
||||
|
||||
log.info("fetch {any}: {d}", .{ url, header.status });
|
||||
|
||||
if (header.status != 200) {
|
||||
return error.BadStatusCode;
|
||||
}
|
||||
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
while (try response.next()) |data| {
|
||||
try arr.appendSlice(arena, try arena.dupe(u8, data));
|
||||
}
|
||||
|
||||
// TODO check content-type
|
||||
|
||||
// check no body
|
||||
if (arr.items.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
fn newHTTPRequest(self: *const Page, method: http.Request.Method, url: *const URL, opts: storage.cookie.LookupOpts) !http.Request {
|
||||
// Don't use the state's request_factory here, since requests made by the
|
||||
// page (i.e. to load <scripts>) should not generate notifications.
|
||||
var request = try self.session.browser.http_client.request(method, &url.uri);
|
||||
errdefer request.deinit();
|
||||
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
try self.state.cookie_jar.forRequest(&url.uri, arr.writer(self.arena), opts);
|
||||
|
||||
if (arr.items.len > 0) {
|
||||
try request.addHeader("Cookie", arr.items, .{});
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
pub const MouseEvent = struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
type: Type,
|
||||
|
||||
const Type = enum {
|
||||
pressed,
|
||||
released,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn mouseEvent(self: *Page, me: MouseEvent) !void {
|
||||
if (me.type != .pressed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = self.renderer.getElementAtPosition(me.x, me.y) orelse return;
|
||||
|
||||
const event = try parser.mouseEventCreate();
|
||||
defer parser.mouseEventDestroy(event);
|
||||
try parser.mouseEventInit(event, "click", .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.x = me.x,
|
||||
.y = me.y,
|
||||
});
|
||||
_ = try parser.elementDispatchEvent(element, @ptrCast(event));
|
||||
}
|
||||
|
||||
fn windowClicked(node: *parser.EventNode, event: *parser.Event) void {
|
||||
const self: *Page = @fieldParentPtr("window_clicked_event_node", node);
|
||||
self._windowClicked(event) catch |err| {
|
||||
log.err("window click handler: {}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn _windowClicked(self: *Page, event: *parser.Event) !void {
|
||||
const target = (try parser.eventTarget(event)) orelse return;
|
||||
const node = parser.eventTargetToNode(target);
|
||||
const tag = (try parser.nodeHTMLGetTagType(node)) orelse return;
|
||||
switch (tag) {
|
||||
.a => {
|
||||
const element: *parser.Element = @ptrCast(node);
|
||||
const href = (try parser.elementGetAttribute(element, "href")) orelse return;
|
||||
|
||||
// We cannot navigate immediately as navigating will delete the DOM tree, which holds this event's node.
|
||||
// As such we schedule the function to be called as soon as possible.
|
||||
// NOTE Using the page.arena assumes that the scheduling loop does use this object after invoking the callback
|
||||
// If that changes we may want to consider storing DelayedNavigation in the session instead.
|
||||
const arena = self.arena;
|
||||
const navi = try arena.create(DelayedNavigation);
|
||||
navi.* = .{
|
||||
.session = self.session,
|
||||
.href = try arena.dupe(u8, href),
|
||||
};
|
||||
_ = try self.state.loop.timeout(0, &navi.navigate_node);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
const DelayedNavigation = struct {
|
||||
navigate_node: Loop.CallbackNode = .{ .func = DelayedNavigation.delay_navigate },
|
||||
session: *Session,
|
||||
href: []const u8,
|
||||
|
||||
fn delay_navigate(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
_ = repeat_delay;
|
||||
const self: *DelayedNavigation = @fieldParentPtr("navigate_node", node);
|
||||
self.session.pageNavigate(self.href) catch |err| {
|
||||
log.err("Delayed navigation error {}", .{err}); // TODO: should we trigger a specific event here?
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Script = struct {
|
||||
kind: Kind,
|
||||
is_async: bool,
|
||||
is_defer: bool,
|
||||
src: ?[]const u8,
|
||||
element: *parser.Element,
|
||||
// The javascript to load after we successfully load the script
|
||||
onload: ?[]const u8,
|
||||
|
||||
// The javascript to load if we have an error executing the script
|
||||
// For now, we ignore this, since we still have a lot of errors that we
|
||||
// shouldn't
|
||||
//onerror: ?[]const u8,
|
||||
|
||||
const Kind = enum {
|
||||
module,
|
||||
javascript,
|
||||
};
|
||||
|
||||
fn init(e: *parser.Element) !?Script {
|
||||
// ignore non-script tags
|
||||
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(e)));
|
||||
if (tag != .script) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (try parser.elementGetAttribute(e, "nomodule") != null) {
|
||||
// these scripts should only be loaded if we don't support modules
|
||||
// but since we do support modules, we can just skip them.
|
||||
return null;
|
||||
}
|
||||
|
||||
const kind = parseKind(try parser.elementGetAttribute(e, "type")) orelse {
|
||||
return null;
|
||||
};
|
||||
|
||||
return .{
|
||||
.kind = kind,
|
||||
.element = e,
|
||||
.src = try parser.elementGetAttribute(e, "src"),
|
||||
.onload = try parser.elementGetAttribute(e, "onload"),
|
||||
.is_async = try parser.elementGetAttribute(e, "async") != null,
|
||||
.is_defer = try parser.elementGetAttribute(e, "defer") != null,
|
||||
};
|
||||
}
|
||||
|
||||
// > type
|
||||
// > Attribute is not set (default), an empty string, or a JavaScript MIME
|
||||
// > type indicates that the script is a "classic script", containing
|
||||
// > JavaScript code.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#attribute_is_not_set_default_an_empty_string_or_a_javascript_mime_type
|
||||
fn parseKind(script_type_: ?[]const u8) ?Kind {
|
||||
const script_type = script_type_ orelse return .javascript;
|
||||
if (script_type.len == 0) {
|
||||
return .javascript;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, script_type, "application/javascript")) return .javascript;
|
||||
if (std.mem.eql(u8, script_type, "text/javascript")) return .javascript;
|
||||
if (std.mem.eql(u8, script_type, "module")) return .module;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn eval(self: *const Script, page: *Page, body: []const u8) !void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(page.scope);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const src = self.src orelse "inline";
|
||||
const res = switch (self.kind) {
|
||||
.javascript => page.scope.exec(body, src),
|
||||
.module => blk: {
|
||||
switch (try page.scope.module(body, src)) {
|
||||
.value => |v| break :blk v,
|
||||
.exception => |e| {
|
||||
log.info("eval module {s}: {s}", .{
|
||||
src,
|
||||
try e.exception(page.arena),
|
||||
});
|
||||
return error.JsErr;
|
||||
},
|
||||
}
|
||||
},
|
||||
} catch {
|
||||
if (try try_catch.err(page.arena)) |msg| {
|
||||
log.info("eval script {s}: {s}", .{ src, msg });
|
||||
}
|
||||
return error.JsErr;
|
||||
};
|
||||
_ = res;
|
||||
|
||||
if (self.onload) |onload| {
|
||||
_ = page.scope.exec(onload, "script_on_load") catch {
|
||||
if (try try_catch.err(page.arena)) |msg| {
|
||||
log.info("eval script onload {s}: {s}", .{ src, msg });
|
||||
}
|
||||
return error.JsErr;
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
pub const NavigateReason = enum {
|
||||
anchor,
|
||||
address_bar,
|
||||
};
|
||||
|
||||
pub const NavigateOpts = struct {
|
||||
cdp_id: ?i64 = null,
|
||||
reason: NavigateReason = .address_bar,
|
||||
};
|
||||
|
||||
fn timestamp() u32 {
|
||||
const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable;
|
||||
return @intCast(ts.sec);
|
||||
}
|
||||
98
src/browser/renderer.zig
Normal file
98
src/browser/renderer.zig
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("netsurf.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// provide very poor abstration to the rest of the code. In theory, we can change
|
||||
// the FlatRenderer to a different implementation, and it'll all just work.
|
||||
pub const Renderer = FlatRenderer;
|
||||
|
||||
// This "renderer" positions elements in a single row in an unspecified order.
|
||||
// The important thing is that elements have a consistent position/index within
|
||||
// that row, which can be turned into a rectangle.
|
||||
const FlatRenderer = struct {
|
||||
allocator: Allocator,
|
||||
|
||||
// key is a @ptrFromInt of the element
|
||||
// value is the index position
|
||||
positions: std.AutoHashMapUnmanaged(u64, u32),
|
||||
|
||||
// given an index, get the element
|
||||
elements: std.ArrayListUnmanaged(u64),
|
||||
|
||||
const Element = @import("dom/element.zig").Element;
|
||||
|
||||
// we expect allocator to be an arena
|
||||
pub fn init(allocator: Allocator) FlatRenderer {
|
||||
return .{
|
||||
.elements = .{},
|
||||
.positions = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
// The DOMRect is always relative to the viewport, not the document the element belongs to.
|
||||
// Element that are not part of the main document, either detached or in a shadow DOM should not call this function.
|
||||
pub fn getRect(self: *FlatRenderer, e: *parser.Element) !Element.DOMRect {
|
||||
var elements = &self.elements;
|
||||
const gop = try self.positions.getOrPut(self.allocator, @intFromPtr(e));
|
||||
var x: u32 = gop.value_ptr.*;
|
||||
if (gop.found_existing == false) {
|
||||
x = @intCast(elements.items.len);
|
||||
try elements.append(self.allocator, @intFromPtr(e));
|
||||
gop.value_ptr.* = x;
|
||||
}
|
||||
|
||||
return .{
|
||||
.x = @floatFromInt(x),
|
||||
.y = 0.0,
|
||||
.width = 1.0,
|
||||
.height = 1.0,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
|
||||
return .{
|
||||
.x = 0.0,
|
||||
.y = 0.0,
|
||||
.width = @floatFromInt(self.width()),
|
||||
.height = @floatFromInt(self.height()),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn width(self: *const FlatRenderer) u32 {
|
||||
return @max(@as(u32, @intCast(self.elements.items.len)), 1); // At least 1 pixel even if empty
|
||||
}
|
||||
|
||||
pub fn height(_: *const FlatRenderer) u32 {
|
||||
return 1;
|
||||
}
|
||||
|
||||
pub fn getElementAtPosition(self: *const FlatRenderer, x: i32, y: i32) ?*parser.Element {
|
||||
if (y != 0 or x < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elements = self.elements.items;
|
||||
return if (x < elements.len) @ptrFromInt(elements[@intCast(x)]) else null;
|
||||
}
|
||||
};
|
||||
142
src/browser/session.zig
Normal file
142
src/browser/session.zig
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Env = @import("env.zig").Env;
|
||||
const Page = @import("page.zig").Page;
|
||||
const Browser = @import("browser.zig").Browser;
|
||||
|
||||
const parser = @import("netsurf.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
|
||||
const log = std.log.scoped(.session);
|
||||
|
||||
// Session is like a browser's tab.
|
||||
// It owns the js env and the loader for all the pages of the session.
|
||||
// You can create successively multiple pages for a session, but you must
|
||||
// deinit a page before running another one.
|
||||
pub const Session = struct {
|
||||
browser: *Browser,
|
||||
|
||||
// Used to create our Inspector and in the BrowserContext.
|
||||
arena: Allocator,
|
||||
|
||||
// The page's arena is unsuitable for data that has to existing while
|
||||
// navigating from one page to another. For example, if we're clicking
|
||||
// on an HREF, the URL exists in the original page (where the click
|
||||
// originated) but also has to exist in the new page.
|
||||
// While we could use the Session's arena, this could accumulate a lot of
|
||||
// memory if we do many navigation events. The `transfer_arena` is meant to
|
||||
// bridge the gap: existing long enough to store any data needed to end one
|
||||
// page and start another.
|
||||
transfer_arena: Allocator,
|
||||
|
||||
executor: Env.ExecutionWorld,
|
||||
storage_shed: storage.Shed,
|
||||
cookie_jar: storage.CookieJar,
|
||||
|
||||
page: ?Page = null,
|
||||
|
||||
pub fn init(self: *Session, browser: *Browser) !void {
|
||||
var executor = try browser.env.newExecutionWorld();
|
||||
errdefer executor.deinit();
|
||||
|
||||
const allocator = browser.app.allocator;
|
||||
self.* = .{
|
||||
.browser = browser,
|
||||
.executor = executor,
|
||||
.arena = browser.session_arena.allocator(),
|
||||
.storage_shed = storage.Shed.init(allocator),
|
||||
.cookie_jar = storage.CookieJar.init(allocator),
|
||||
.transfer_arena = browser.transfer_arena.allocator(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Session) void {
|
||||
if (self.page != null) {
|
||||
self.removePage();
|
||||
}
|
||||
self.cookie_jar.deinit();
|
||||
self.storage_shed.deinit();
|
||||
self.executor.deinit();
|
||||
}
|
||||
|
||||
// NOTE: the caller is not the owner of the returned value,
|
||||
// the pointer on Page is just returned as a convenience
|
||||
pub fn createPage(self: *Session) !*Page {
|
||||
std.debug.assert(self.page == null);
|
||||
|
||||
// Start netsurf memory arena.
|
||||
// We need to init this early as JS event handlers may be registered through Runtime.evaluate before the first html doc is loaded
|
||||
try parser.init();
|
||||
|
||||
const page_arena = &self.browser.page_arena;
|
||||
_ = page_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, page_arena.allocator(), self);
|
||||
|
||||
// start JS env
|
||||
log.debug("start new js scope", .{});
|
||||
// Inform CDP the main page has been created such that additional context for other Worlds can be created as well
|
||||
self.browser.notification.dispatch(.page_created, page);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
pub fn removePage(self: *Session) void {
|
||||
// Inform CDP the page is going to be removed, allowing other worlds to remove themselves before the main one
|
||||
self.browser.notification.dispatch(.page_remove, .{});
|
||||
|
||||
std.debug.assert(self.page != null);
|
||||
// Reset all existing callbacks.
|
||||
self.browser.app.loop.reset();
|
||||
self.executor.endScope();
|
||||
self.page = null;
|
||||
|
||||
// clear netsurf memory arena.
|
||||
parser.deinit();
|
||||
}
|
||||
|
||||
pub fn currentPage(self: *Session) ?*Page {
|
||||
return &(self.page orelse return null);
|
||||
}
|
||||
|
||||
pub fn pageNavigate(self: *Session, url_string: []const u8) !void {
|
||||
// currently, this is only called from the page, so let's hope
|
||||
// it isn't null!
|
||||
std.debug.assert(self.page != null);
|
||||
|
||||
defer _ = self.browser.transfer_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
|
||||
|
||||
// it's safe to use the transfer arena here, because the page will
|
||||
// eventually clone the URL using its own page_arena (after it gets
|
||||
// the final URL, possibly following redirects)
|
||||
const url = try self.page.?.url.resolve(self.transfer_arena, url_string);
|
||||
|
||||
self.removePage();
|
||||
var page = try self.createPage();
|
||||
return page.navigate(url, .{
|
||||
.reason = .anchor,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -19,7 +19,7 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Callback = Env.Callback;
|
||||
const Function = Env.Function;
|
||||
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const EventHandler = @import("../events/event.zig").EventHandler;
|
||||
@@ -35,18 +35,18 @@ pub const XMLHttpRequestEventTarget = struct {
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
onloadstart_cbk: ?Callback = null,
|
||||
onprogress_cbk: ?Callback = null,
|
||||
onabort_cbk: ?Callback = null,
|
||||
onload_cbk: ?Callback = null,
|
||||
ontimeout_cbk: ?Callback = null,
|
||||
onloadend_cbk: ?Callback = null,
|
||||
onloadstart_cbk: ?Function = null,
|
||||
onprogress_cbk: ?Function = null,
|
||||
onabort_cbk: ?Function = null,
|
||||
onload_cbk: ?Function = null,
|
||||
ontimeout_cbk: ?Function = null,
|
||||
onloadend_cbk: ?Function = null,
|
||||
|
||||
fn register(
|
||||
self: *XMLHttpRequestEventTarget,
|
||||
alloc: std.mem.Allocator,
|
||||
typ: []const u8,
|
||||
cbk: Callback,
|
||||
cbk: Function,
|
||||
) !void {
|
||||
const target = @as(*parser.EventTarget, @ptrCast(self));
|
||||
const eh = try EventHandler.init(alloc, try cbk.withThis(target));
|
||||
@@ -69,51 +69,51 @@ pub const XMLHttpRequestEventTarget = struct {
|
||||
try parser.eventTargetRemoveEventListener(et, typ, lst.?, false);
|
||||
}
|
||||
|
||||
pub fn get_onloadstart(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_onloadstart(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onloadstart_cbk;
|
||||
}
|
||||
pub fn get_onprogress(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_onprogress(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onprogress_cbk;
|
||||
}
|
||||
pub fn get_onabort(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_onabort(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onabort_cbk;
|
||||
}
|
||||
pub fn get_onload(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_onload(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onload_cbk;
|
||||
}
|
||||
pub fn get_ontimeout(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_ontimeout(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.ontimeout_cbk;
|
||||
}
|
||||
pub fn get_onloadend(self: *XMLHttpRequestEventTarget) ?Callback {
|
||||
pub fn get_onloadend(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onloadend_cbk;
|
||||
}
|
||||
|
||||
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.onloadstart_cbk) |cbk| try self.unregister("loadstart", cbk.id);
|
||||
try self.register(state.arena, "loadstart", handler);
|
||||
self.onloadstart_cbk = handler;
|
||||
}
|
||||
pub fn set_onprogress(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_onprogress(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.onprogress_cbk) |cbk| try self.unregister("progress", cbk.id);
|
||||
try self.register(state.arena, "progress", handler);
|
||||
self.onprogress_cbk = handler;
|
||||
}
|
||||
pub fn set_onabort(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_onabort(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.onabort_cbk) |cbk| try self.unregister("abort", cbk.id);
|
||||
try self.register(state.arena, "abort", handler);
|
||||
self.onabort_cbk = handler;
|
||||
}
|
||||
pub fn set_onload(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_onload(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.onload_cbk) |cbk| try self.unregister("load", cbk.id);
|
||||
try self.register(state.arena, "load", handler);
|
||||
self.onload_cbk = handler;
|
||||
}
|
||||
pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.ontimeout_cbk) |cbk| try self.unregister("timeout", cbk.id);
|
||||
try self.register(state.arena, "timeout", handler);
|
||||
self.ontimeout_cbk = handler;
|
||||
}
|
||||
pub fn set_onloadend(self: *XMLHttpRequestEventTarget, handler: Callback, state: *SessionState) !void {
|
||||
pub fn set_onloadend(self: *XMLHttpRequestEventTarget, handler: Function, state: *SessionState) !void {
|
||||
if (self.onloadend_cbk) |cbk| try self.unregister("loadend", cbk.id);
|
||||
try self.register(state.arena, "loadend", handler);
|
||||
self.onloadend_cbk = handler;
|
||||
|
||||
459
src/browser/xhr/form_data.zig
Normal file
459
src/browser/xhr/form_data.zig
Normal file
@@ -0,0 +1,459 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const iterator = @import("../iterator/iterator.zig");
|
||||
const SessionState = @import("../env.zig").SessionState;
|
||||
|
||||
const log = std.log.scoped(.form_data);
|
||||
|
||||
pub const Interfaces = .{
|
||||
FormData,
|
||||
KeyIterable,
|
||||
ValueIterable,
|
||||
EntryIterable,
|
||||
};
|
||||
|
||||
// We store the values in an ArrayList rather than a an
|
||||
// StringArrayHashMap([]const u8) because of the way the iterators (i.e., keys(),
|
||||
// values() and entries()) work. The FormData can contain duplicate keys, and
|
||||
// each iteration yields 1 key=>value pair. So, given:
|
||||
//
|
||||
// let f = new FormData();
|
||||
// f.append('a', '1');
|
||||
// f.append('a', '2');
|
||||
//
|
||||
// Then we'd expect f.keys(), f.values() and f.entries() to yield 2 results:
|
||||
// ['a', '1']
|
||||
// ['a', '2']
|
||||
//
|
||||
// This is much easier to do with an ArrayList than a HashMap, especially given
|
||||
// that the FormData could be mutated while iterating.
|
||||
// The downside is that most of the normal operations are O(N).
|
||||
|
||||
// https://xhr.spec.whatwg.org/#interface-formdata
|
||||
pub const FormData = struct {
|
||||
entries: std.ArrayListUnmanaged(Entry),
|
||||
|
||||
pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, state: *SessionState) !FormData {
|
||||
const form = form_ orelse return .{ .entries = .empty };
|
||||
return fromForm(form, submitter_, state, .{});
|
||||
}
|
||||
|
||||
const FromFormOpts = struct {
|
||||
// Uses the state.arena if null. This is needed for when we're handling
|
||||
// form submission from the Page, and we want to capture the form within
|
||||
// the session's transfer_arena.
|
||||
allocator: ?Allocator = null,
|
||||
};
|
||||
pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, state: *SessionState, opts: FromFormOpts) !FormData {
|
||||
const entries = try collectForm(opts.allocator orelse state.arena, form, submitter_, state);
|
||||
return .{ .entries = entries };
|
||||
}
|
||||
|
||||
pub fn _get(self: *const FormData, key: []const u8) ?[]const u8 {
|
||||
const result = self.find(key) orelse return null;
|
||||
return result.entry.value;
|
||||
}
|
||||
|
||||
pub fn _getAll(self: *const FormData, key: []const u8, state: *SessionState) ![][]const u8 {
|
||||
const arena = state.call_arena;
|
||||
var arr: std.ArrayListUnmanaged([]const u8) = .empty;
|
||||
for (self.entries.items) |entry| {
|
||||
if (std.mem.eql(u8, key, entry.key)) {
|
||||
try arr.append(arena, entry.value);
|
||||
}
|
||||
}
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
pub fn _has(self: *const FormData, key: []const u8) bool {
|
||||
return self.find(key) != null;
|
||||
}
|
||||
|
||||
// TODO: value should be a string or blog
|
||||
// TODO: another optional parameter for the filename
|
||||
pub fn _set(self: *FormData, key: []const u8, value: []const u8, state: *SessionState) !void {
|
||||
self._delete(key);
|
||||
return self._append(key, value, state);
|
||||
}
|
||||
|
||||
// TODO: value should be a string or blog
|
||||
// TODO: another optional parameter for the filename
|
||||
pub fn _append(self: *FormData, key: []const u8, value: []const u8, state: *SessionState) !void {
|
||||
const arena = state.arena;
|
||||
return self.entries.append(arena, .{ .key = try arena.dupe(u8, key), .value = try arena.dupe(u8, value) });
|
||||
}
|
||||
|
||||
pub fn _delete(self: *FormData, key: []const u8) void {
|
||||
var i: usize = 0;
|
||||
while (i < self.entries.items.len) {
|
||||
const entry = self.entries.items[i];
|
||||
if (std.mem.eql(u8, key, entry.key)) {
|
||||
_ = self.entries.swapRemove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _keys(self: *const FormData) KeyIterable {
|
||||
return .{ .inner = .{ .entries = &self.entries } };
|
||||
}
|
||||
|
||||
pub fn _values(self: *const FormData) ValueIterable {
|
||||
return .{ .inner = .{ .entries = &self.entries } };
|
||||
}
|
||||
|
||||
pub fn _entries(self: *const FormData) EntryIterable {
|
||||
return .{ .inner = .{ .entries = &self.entries } };
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *const FormData) EntryIterable {
|
||||
return self._entries();
|
||||
}
|
||||
|
||||
const FindResult = struct {
|
||||
index: usize,
|
||||
entry: Entry,
|
||||
};
|
||||
|
||||
fn find(self: *const FormData, key: []const u8) ?FindResult {
|
||||
for (self.entries.items, 0..) |entry, i| {
|
||||
if (std.mem.eql(u8, key, entry.key)) {
|
||||
return .{ .index = i, .entry = entry };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const Entry = struct {
|
||||
key: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
const KeyIterable = iterator.Iterable(KeyIterator, "FormDataKeyIterator");
|
||||
const ValueIterable = iterator.Iterable(ValueIterator, "FormDataValueIterator");
|
||||
const EntryIterable = iterator.Iterable(EntryIterator, "FormDataEntryIterator");
|
||||
|
||||
const KeyIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(Entry),
|
||||
|
||||
pub fn _next(self: *KeyIterator) ?[]const u8 {
|
||||
const index = self.index;
|
||||
if (index == self.entries.items.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
return self.entries.items[index].key;
|
||||
}
|
||||
};
|
||||
|
||||
const ValueIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(Entry),
|
||||
|
||||
pub fn _next(self: *ValueIterator) ?[]const u8 {
|
||||
const index = self.index;
|
||||
if (index == self.entries.items.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
return self.entries.items[index].value;
|
||||
}
|
||||
};
|
||||
|
||||
const EntryIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(Entry),
|
||||
|
||||
pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } {
|
||||
const index = self.index;
|
||||
if (index == self.entries.items.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
const entry = self.entries.items[index];
|
||||
return .{ entry.key, entry.value };
|
||||
}
|
||||
};
|
||||
|
||||
fn collectForm(arena: Allocator, form: *parser.Form, submitter_: ?*parser.ElementHTML, state: *SessionState) !std.ArrayListUnmanaged(Entry) {
|
||||
const collection = try parser.formGetCollection(form);
|
||||
const len = try parser.htmlCollectionGetLength(collection);
|
||||
|
||||
var entries: std.ArrayListUnmanaged(Entry) = .empty;
|
||||
try entries.ensureTotalCapacity(arena, len);
|
||||
|
||||
const submitter_name_ = try getSubmitterName(submitter_);
|
||||
|
||||
for (0..len) |i| {
|
||||
const node = try parser.htmlCollectionItem(collection, @intCast(i));
|
||||
const element = parser.nodeToElement(node);
|
||||
|
||||
// must have a name
|
||||
const name = try parser.elementGetAttribute(element, "name") orelse continue;
|
||||
if (try parser.elementGetAttribute(element, "disabled") != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(element)));
|
||||
switch (tag) {
|
||||
.input => {
|
||||
const tpe = try parser.elementGetAttribute(element, "type") orelse "";
|
||||
if (std.ascii.eqlIgnoreCase(tpe, "image")) {
|
||||
if (submitter_name_) |submitter_name| {
|
||||
if (std.mem.eql(u8, submitter_name, name)) {
|
||||
try entries.append(arena, .{
|
||||
.key = try std.fmt.allocPrint(arena, "{s}.x", .{name}),
|
||||
.value = "0",
|
||||
});
|
||||
try entries.append(arena, .{
|
||||
.key = try std.fmt.allocPrint(arena, "{s}.y", .{name}),
|
||||
.value = "0",
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(tpe, "checkbox") or std.ascii.eqlIgnoreCase(tpe, "radio")) {
|
||||
if (try parser.inputGetChecked(@ptrCast(element)) == false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(tpe, "submit")) {
|
||||
if (submitter_name_ == null or !std.mem.eql(u8, submitter_name_.?, name)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const value = (try parser.elementGetAttribute(element, "value")) orelse "";
|
||||
try entries.append(arena, .{ .key = name, .value = value });
|
||||
},
|
||||
.select => {
|
||||
const select: *parser.Select = @ptrCast(node);
|
||||
try collectSelectValues(arena, select, name, &entries, state);
|
||||
},
|
||||
.textarea => {
|
||||
const textarea: *parser.TextArea = @ptrCast(node);
|
||||
const value = try parser.textareaGetValue(textarea);
|
||||
try entries.append(arena, .{ .key = name, .value = value });
|
||||
},
|
||||
.button => if (submitter_name_) |submitter_name| {
|
||||
if (std.mem.eql(u8, submitter_name, name)) {
|
||||
const value = (try parser.elementGetAttribute(element, "value")) orelse "";
|
||||
try entries.append(arena, .{ .key = name, .value = value });
|
||||
}
|
||||
},
|
||||
else => {
|
||||
log.warn("unsupported form element: {s}\n", .{@tagName(tag)});
|
||||
continue;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *std.ArrayListUnmanaged(Entry), state: *SessionState) !void {
|
||||
const HTMLSelectElement = @import("../html/select.zig").HTMLSelectElement;
|
||||
|
||||
// Go through the HTMLSelectElement because it has specific logic for handling
|
||||
// the default selected option, which libdom doesn't properly handle
|
||||
const selected_index = try HTMLSelectElement.get_selectedIndex(select, state);
|
||||
if (selected_index == -1) {
|
||||
return;
|
||||
}
|
||||
std.debug.assert(selected_index >= 0);
|
||||
|
||||
const options = try parser.selectGetOptions(select);
|
||||
const is_multiple = try parser.selectGetMultiple(select);
|
||||
if (is_multiple == false) {
|
||||
const option = try parser.optionCollectionItem(options, @intCast(selected_index));
|
||||
|
||||
if (try parser.elementGetAttribute(@ptrCast(option), "disabled") != null) {
|
||||
return;
|
||||
}
|
||||
const value = try parser.optionGetValue(option);
|
||||
return entries.append(arena, .{ .key = name, .value = value });
|
||||
}
|
||||
|
||||
const len = try parser.optionCollectionGetLength(options);
|
||||
|
||||
// we can go directly to the first one
|
||||
for (@intCast(selected_index)..len) |i| {
|
||||
const option = try parser.optionCollectionItem(options, @intCast(i));
|
||||
if (try parser.elementGetAttribute(@ptrCast(option), "disabled") != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try parser.optionGetSelected(option)) {
|
||||
const value = try parser.optionGetValue(option);
|
||||
try entries.append(arena, .{ .key = name, .value = value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getSubmitterName(submitter_: ?*parser.ElementHTML) !?[]const u8 {
|
||||
const submitter = submitter_ orelse return null;
|
||||
|
||||
const tag = try parser.elementHTMLGetTagType(submitter);
|
||||
const element: *parser.Element = @ptrCast(submitter);
|
||||
const name = try parser.elementGetAttribute(element, "name");
|
||||
|
||||
switch (tag) {
|
||||
.button => return name,
|
||||
.input => {
|
||||
const tpe = (try parser.elementGetAttribute(element, "type")) orelse "";
|
||||
// only an image type can be a sumbitter
|
||||
if (std.ascii.eqlIgnoreCase(tpe, "image") or std.ascii.eqlIgnoreCase(tpe, "submit")) {
|
||||
return name;
|
||||
}
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.FormData" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html =
|
||||
\\ <form id="form1">
|
||||
\\ <input id="has_no_name" value="nope1">
|
||||
\\ <input id="is_disabled" disabled value="nope2">
|
||||
\\
|
||||
\\ <input name="txt-1" value="txt-1-v">
|
||||
\\ <input name="txt-2" value="txt-2-v" type=password>
|
||||
\\
|
||||
\\ <input name="chk-3" value="chk-3-va" type=checkbox>
|
||||
\\ <input name="chk-3" value="chk-3-vb" type=checkbox checked>
|
||||
\\ <input name="chk-3" value="chk-3-vc" type=checkbox checked>
|
||||
\\ <input name="chk-4" value="chk-4-va" type=checkbox>
|
||||
\\ <input name="chk-4" value="chk-4-va" type=checkbox>
|
||||
\\
|
||||
\\ <input name="rdi-1" value="rdi-1-va" type=radio>
|
||||
\\ <input name="rdi-1" value="rdi-1-vb" type=radio>
|
||||
\\ <input name="rdi-1" value="rdi-1-vc" type=radio checked>
|
||||
\\ <input name="rdi-2" value="rdi-2-va" type=radio>
|
||||
\\ <input name="rdi-2" value="rdi-2-vb" type=radio>
|
||||
\\
|
||||
\\ <textarea name="ta-1"> ta-1-v</textarea>
|
||||
\\ <textarea name="ta"></textarea>
|
||||
\\
|
||||
\\ <input type=hidden name=h1 value="h1-v">
|
||||
\\ <input type=hidden name=h2 value="h2-v" disabled=disabled>
|
||||
\\
|
||||
\\ <select name="sel-1"><option>blue<option>red</select>
|
||||
\\ <select name="sel-2"><option>blue<option value=sel-2-v selected>red</select>
|
||||
\\ <select name="sel-3"><option disabled>nope1<option>nope2</select>
|
||||
\\ <select name="mlt-1" multiple><option>water<option>tea</select>
|
||||
\\ <select name="mlt-2" multiple><option selected>water<option selected>tea<option>coffee</select>
|
||||
\\ <input type=submit id=s1 name=s1 value=s1-v>
|
||||
\\ <input type=submit name=s2 value=s2-v>
|
||||
\\ <input type=image name=i1 value=i1-v>
|
||||
\\ </form>
|
||||
});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let f = new FormData()", null },
|
||||
.{ "f.get('a')", "null" },
|
||||
.{ "f.has('a')", "false" },
|
||||
.{ "f.getAll('a')", "" },
|
||||
.{ "f.delete('a')", "undefined" },
|
||||
|
||||
.{ "f.set('a', 1)", "undefined" },
|
||||
.{ "f.has('a')", "true" },
|
||||
.{ "f.get('a')", "1" },
|
||||
.{ "f.getAll('a')", "1" },
|
||||
|
||||
.{ "f.append('a', 2)", "undefined" },
|
||||
.{ "f.has('a')", "true" },
|
||||
.{ "f.get('a')", "1" },
|
||||
.{ "f.getAll('a')", "1,2" },
|
||||
|
||||
.{ "f.append('b', '3')", "undefined" },
|
||||
.{ "f.has('a')", "true" },
|
||||
.{ "f.get('a')", "1" },
|
||||
.{ "f.getAll('a')", "1,2" },
|
||||
.{ "f.has('b')", "true" },
|
||||
.{ "f.get('b')", "3" },
|
||||
.{ "f.getAll('b')", "3" },
|
||||
|
||||
.{ "let acc = [];", null },
|
||||
.{ "for (const key of f.keys()) { acc.push(key) }; acc;", "a,a,b" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const value of f.values()) { acc.push(value) }; acc;", "1,2,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of f.entries()) { acc.push(entry) }; acc;", "a,1,a,2,b,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of f) { acc.push(entry) }; acc;", "a,1,a,2,b,3" },
|
||||
|
||||
.{ "f.delete('a')", "undefined" },
|
||||
.{ "f.has('a')", "false" },
|
||||
.{ "f.has('b')", "true" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const key of f.keys()) { acc.push(key) }; acc;", "b" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const value of f.values()) { acc.push(value) }; acc;", "3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of f.entries()) { acc.push(entry) }; acc;", "b,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of f) { acc.push(entry) }; acc;", "b,3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let form1 = document.getElementById('form1')", null },
|
||||
.{ "let submit1 = document.getElementById('s1')", null },
|
||||
.{ "let f2 = new FormData(form1, submit1)", null },
|
||||
.{ "acc = '';", null },
|
||||
.{
|
||||
\\ for (const entry of f2) {
|
||||
\\ acc += entry[0] + '=' + entry[1] + '\n';
|
||||
\\ };
|
||||
\\ acc.slice(0, -1)
|
||||
,
|
||||
\\txt-1=txt-1-v
|
||||
\\txt-2=txt-2-v
|
||||
\\chk-3=chk-3-vb
|
||||
\\chk-3=chk-3-vc
|
||||
\\rdi-1=rdi-1-vc
|
||||
\\ta-1= ta-1-v
|
||||
\\ta=
|
||||
\\h1=h1-v
|
||||
\\sel-1=blue
|
||||
\\sel-2=sel-2-v
|
||||
\\mlt-2=water
|
||||
\\mlt-2=tea
|
||||
\\s1=s1-v
|
||||
},
|
||||
}, .{});
|
||||
}
|
||||
@@ -20,7 +20,6 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const DOMError = @import("../netsurf.zig").DOMError;
|
||||
const DOMException = @import("../dom/exceptions.zig").DOMException;
|
||||
|
||||
const ProgressEvent = @import("progress_event.zig").ProgressEvent;
|
||||
const XMLHttpRequestEventTarget = @import("event_target.zig").XMLHttpRequestEventTarget;
|
||||
@@ -81,7 +80,6 @@ const XMLHttpRequestBodyInit = union(enum) {
|
||||
pub const XMLHttpRequest = struct {
|
||||
proto: XMLHttpRequestEventTarget = XMLHttpRequestEventTarget{},
|
||||
arena: Allocator,
|
||||
client: *http.Client,
|
||||
request: ?http.Request = null,
|
||||
|
||||
priv_state: PrivState = .new,
|
||||
@@ -253,7 +251,6 @@ pub const XMLHttpRequest = struct {
|
||||
.state = .unsent,
|
||||
.url = null,
|
||||
.origin_url = session_state.url,
|
||||
.client = session_state.http_client,
|
||||
.cookie_jar = session_state.cookie_jar,
|
||||
};
|
||||
}
|
||||
@@ -279,13 +276,6 @@ pub const XMLHttpRequest = struct {
|
||||
self.priv_state = .new;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *XMLHttpRequest, alloc: Allocator) void {
|
||||
if (self.response_obj) |v| {
|
||||
v.deinit();
|
||||
}
|
||||
self.proto.deinit(alloc);
|
||||
}
|
||||
|
||||
pub fn get_readyState(self: *XMLHttpRequest) u16 {
|
||||
return @intFromEnum(self.state);
|
||||
}
|
||||
@@ -428,7 +418,7 @@ pub const XMLHttpRequest = struct {
|
||||
self.send_flag = true;
|
||||
self.priv_state = .open;
|
||||
|
||||
self.request = try self.client.request(self.method, &self.url.?.uri);
|
||||
self.request = try session_state.request_factory.create(self.method, &self.url.?.uri);
|
||||
var request = &self.request.?;
|
||||
errdefer request.deinit();
|
||||
|
||||
@@ -497,7 +487,7 @@ pub const XMLHttpRequest = struct {
|
||||
self.state = .loading;
|
||||
self.dispatchEvt("readystatechange");
|
||||
|
||||
try self.cookie_jar.populateFromResponse(self.request.?.uri, &header);
|
||||
try self.cookie_jar.populateFromResponse(self.request.?.request_uri, &header);
|
||||
}
|
||||
|
||||
if (progress.data) |data| {
|
||||
@@ -813,7 +803,7 @@ test "Browser.XHR.XMLHttpRequest" {
|
||||
.{ "req.status", "200" },
|
||||
.{ "req.statusText", "OK" },
|
||||
.{ "req.getResponseHeader('Content-Type')", "text/html; charset=utf-8" },
|
||||
.{ "req.getAllResponseHeaders().length", "61" },
|
||||
.{ "req.getAllResponseHeaders().length", "80" },
|
||||
.{ "req.responseText.length", "100" },
|
||||
.{ "req.response.length == req.responseText.length", "true" },
|
||||
.{ "req.responseXML instanceof Document", "true" },
|
||||
|
||||
@@ -24,8 +24,8 @@ const App = @import("../app.zig").App;
|
||||
const Env = @import("../browser/env.zig").Env;
|
||||
const asUint = @import("../str/parser.zig").asUint;
|
||||
const Browser = @import("../browser/browser.zig").Browser;
|
||||
const Session = @import("../browser/browser.zig").Session;
|
||||
const Page = @import("../browser/browser.zig").Page;
|
||||
const Session = @import("../browser/session.zig").Session;
|
||||
const Page = @import("../browser/page.zig").Page;
|
||||
const Inspector = @import("../browser/env.zig").Env.Inspector;
|
||||
const Incrementing = @import("../id.zig").Incrementing;
|
||||
const Notification = @import("../notification.zig").Notification;
|
||||
@@ -69,6 +69,9 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
||||
// 1 message at a time.
|
||||
message_arena: std.heap.ArenaAllocator,
|
||||
|
||||
// Used for processing notifications within a browser context.
|
||||
notification_arena: std.heap.ArenaAllocator,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(app: *App, client: TypeProvider.Client) !Self {
|
||||
@@ -82,6 +85,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
||||
.allocator = allocator,
|
||||
.browser_context = null,
|
||||
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.notification_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,6 +95,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
||||
}
|
||||
self.browser.deinit();
|
||||
self.message_arena.deinit();
|
||||
self.notification_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn handleMessage(self: *Self, msg: []const u8) bool {
|
||||
@@ -259,7 +264,7 @@ pub fn CDPT(comptime TypeProvider: type) type {
|
||||
});
|
||||
}
|
||||
|
||||
fn sendJSON(self: *Self, message: anytype) !void {
|
||||
pub fn sendJSON(self: *Self, message: anytype) !void {
|
||||
return self.client.sendJSON(message, .{
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
@@ -283,6 +288,12 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
// Points to the session arena
|
||||
arena: Allocator,
|
||||
|
||||
// From the parent's notification_arena.allocator(). Most of the CDP
|
||||
// code paths deal with a cmd which has its own arena (from the
|
||||
// message_arena). But notifications happen outside of the typical CDP
|
||||
// request->response, and thus don't have a cmd and don't have an arena.
|
||||
notification_arena: Allocator,
|
||||
|
||||
// Maps to our Page. (There are other types of targets, but we only
|
||||
// deal with "pages" for now). Since we only allow 1 open page at a
|
||||
// time, we only have 1 target_id.
|
||||
@@ -314,7 +325,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
const allocator = cdp.allocator;
|
||||
|
||||
const session = try cdp.browser.newSession();
|
||||
const arena = session.arena.allocator();
|
||||
const arena = session.arena;
|
||||
|
||||
const inspector = try cdp.browser.env.newInspector(arena, self);
|
||||
|
||||
@@ -336,6 +347,7 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
.node_search_list = undefined,
|
||||
.isolated_world = null,
|
||||
.inspector = inspector,
|
||||
.notification_arena = cdp.notification_arena.allocator(),
|
||||
};
|
||||
self.node_search_list = Node.Search.List.init(allocator, &self.node_registry);
|
||||
errdefer self.deinit();
|
||||
@@ -372,12 +384,11 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
return error.CurrentlyOnly1IsolatedWorldSupported;
|
||||
}
|
||||
|
||||
var executor = try self.cdp.browser.env.newExecutor();
|
||||
var executor = try self.cdp.browser.env.newExecutionWorld();
|
||||
errdefer executor.deinit();
|
||||
|
||||
self.isolated_world = .{
|
||||
.name = try self.arena.dupe(u8, world_name),
|
||||
.scope = null,
|
||||
.executor = executor,
|
||||
.grant_universal_access = grant_universal_access,
|
||||
};
|
||||
@@ -398,6 +409,16 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
return if (raw_url.len == 0) null else raw_url;
|
||||
}
|
||||
|
||||
pub fn networkEnable(self: *Self) !void {
|
||||
try self.cdp.browser.notification.register(.http_request_start, self, onHttpRequestStart);
|
||||
try self.cdp.browser.notification.register(.http_request_complete, self, onHttpRequestComplete);
|
||||
}
|
||||
|
||||
pub fn networkDisable(self: *Self) void {
|
||||
self.cdp.browser.notification.unregister(.http_request_start, self);
|
||||
self.cdp.browser.notification.unregister(.http_request_complete, self);
|
||||
}
|
||||
|
||||
pub fn onPageRemove(ctx: *anyopaque, _: Notification.PageRemove) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
return @import("domains/page.zig").pageRemove(self);
|
||||
@@ -410,7 +431,8 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
|
||||
pub fn onPageNavigate(ctx: *anyopaque, data: *const Notification.PageNavigate) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
return @import("domains/page.zig").pageNavigate(self, data);
|
||||
defer self.resetNotificationArena();
|
||||
return @import("domains/page.zig").pageNavigate(self.notification_arena, self, data);
|
||||
}
|
||||
|
||||
pub fn onPageNavigated(ctx: *anyopaque, data: *const Notification.PageNavigated) !void {
|
||||
@@ -418,6 +440,22 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
return @import("domains/page.zig").pageNavigated(self, data);
|
||||
}
|
||||
|
||||
pub fn onHttpRequestStart(ctx: *anyopaque, data: *const Notification.RequestStart) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
defer self.resetNotificationArena();
|
||||
return @import("domains/network.zig").httpRequestStart(self.notification_arena, self, data);
|
||||
}
|
||||
|
||||
pub fn onHttpRequestComplete(ctx: *anyopaque, data: *const Notification.RequestComplete) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
defer self.resetNotificationArena();
|
||||
return @import("domains/network.zig").httpRequestComplete(self.notification_arena, self, data);
|
||||
}
|
||||
|
||||
fn resetNotificationArena(self: *Self) void {
|
||||
defer _ = self.cdp.notification_arena.reset(.{ .retain_with_limit = 1024 * 64 });
|
||||
}
|
||||
|
||||
pub fn callInspector(self: *const Self, msg: []const u8) void {
|
||||
self.inspector.send(msg);
|
||||
// force running micro tasks after send input to the inspector.
|
||||
@@ -511,18 +549,15 @@ pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
/// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts.
|
||||
const IsolatedWorld = struct {
|
||||
name: []const u8,
|
||||
scope: ?*Env.Scope,
|
||||
executor: Env.Executor,
|
||||
executor: Env.ExecutionWorld,
|
||||
grant_universal_access: bool,
|
||||
|
||||
pub fn deinit(self: *IsolatedWorld) void {
|
||||
self.executor.deinit();
|
||||
self.scope = null;
|
||||
}
|
||||
pub fn removeContext(self: *IsolatedWorld) !void {
|
||||
if (self.scope == null) return error.NoIsolatedContextToRemove;
|
||||
if (self.executor.scope == null) return error.NoIsolatedContextToRemove;
|
||||
self.executor.endScope();
|
||||
self.scope = null;
|
||||
}
|
||||
|
||||
// The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
|
||||
@@ -531,8 +566,8 @@ const IsolatedWorld = struct {
|
||||
// This also means this pointer becomes invalid after removePage untill a new page is created.
|
||||
// Currently we have only 1 page/frame and thus also only 1 state in the isolate world.
|
||||
pub fn createContext(self: *IsolatedWorld, page: *Page) !void {
|
||||
if (self.scope != null) return error.Only1IsolatedContextSupported;
|
||||
self.scope = try self.executor.startScope(&page.window, &page.state, {}, false);
|
||||
if (self.executor.scope != null) return error.Only1IsolatedContextSupported;
|
||||
_ = try self.executor.startScope(&page.window, &page.state, {}, false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,10 +17,12 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Node = @import("../Node.zig");
|
||||
const css = @import("../../browser/dom/css.zig");
|
||||
const parser = @import("../../browser/netsurf.zig");
|
||||
const dom_node = @import("../../browser/dom/node.zig");
|
||||
const Element = @import("../../browser/dom/element.zig").Element;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
@@ -29,8 +31,13 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
performSearch,
|
||||
getSearchResults,
|
||||
discardSearchResults,
|
||||
querySelector,
|
||||
querySelectorAll,
|
||||
resolveNode,
|
||||
describeNode,
|
||||
scrollIntoViewIfNeeded,
|
||||
getContentQuads,
|
||||
getBoxModel,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
@@ -39,8 +46,13 @@ pub fn processMessage(cmd: anytype) !void {
|
||||
.performSearch => return performSearch(cmd),
|
||||
.getSearchResults => return getSearchResults(cmd),
|
||||
.discardSearchResults => return discardSearchResults(cmd),
|
||||
.querySelector => return querySelector(cmd),
|
||||
.querySelectorAll => return querySelectorAll(cmd),
|
||||
.resolveNode => return resolveNode(cmd),
|
||||
.describeNode => return describeNode(cmd),
|
||||
.scrollIntoViewIfNeeded => return scrollIntoViewIfNeeded(cmd),
|
||||
.getContentQuads => return getContentQuads(cmd),
|
||||
.getBoxModel => return getBoxModel(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +65,7 @@ fn getDocument(cmd: anytype) !void {
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const doc = page.doc orelse return error.DocumentNotLoaded;
|
||||
const doc = parser.documentHTMLToDocument(page.window.document);
|
||||
|
||||
const node = try bc.node_registry.register(parser.documentToNode(doc));
|
||||
return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{}) }, .{});
|
||||
@@ -68,7 +80,7 @@ fn performSearch(cmd: anytype) !void {
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const doc = page.doc orelse return error.DocumentNotLoaded;
|
||||
const doc = parser.documentHTMLToDocument(page.window.document);
|
||||
|
||||
const allocator = cmd.cdp.allocator;
|
||||
var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query);
|
||||
@@ -182,6 +194,60 @@ fn getSearchResults(cmd: anytype) !void {
|
||||
return cmd.sendResult(.{ .nodeIds = node_ids[params.fromIndex..params.toIndex] }, .{});
|
||||
}
|
||||
|
||||
fn querySelector(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: Node.Id,
|
||||
selector: []const u8,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse return error.UnknownNode;
|
||||
|
||||
const selected_node = try css.querySelector(
|
||||
cmd.arena,
|
||||
node._node,
|
||||
params.selector,
|
||||
) orelse return error.NodeNotFoundForGivenId;
|
||||
|
||||
const registered_node = try bc.node_registry.register(selected_node);
|
||||
|
||||
// Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results.
|
||||
var array = [1]*parser.Node{selected_node};
|
||||
try dispatchSetChildNodes(cmd, array[0..]);
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.nodeId = registered_node.id,
|
||||
}, .{});
|
||||
}
|
||||
|
||||
fn querySelectorAll(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: Node.Id,
|
||||
selector: []const u8,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const node = bc.node_registry.lookup_by_id.get(params.nodeId) orelse return error.UnknownNode;
|
||||
|
||||
const arena = cmd.arena;
|
||||
const selected_nodes = try css.querySelectorAll(arena, node._node, params.selector);
|
||||
const nodes = selected_nodes.nodes.items;
|
||||
|
||||
const node_ids = try arena.alloc(Node.Id, nodes.len);
|
||||
for (nodes, node_ids) |selected_node, *node_id| {
|
||||
node_id.* = (try bc.node_registry.register(selected_node)).id;
|
||||
}
|
||||
|
||||
// Dispatch setChildNodesEvents to inform the client of the subpart of node tree covering the results.
|
||||
try dispatchSetChildNodes(cmd, nodes);
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.nodeIds = node_ids,
|
||||
}, .{});
|
||||
}
|
||||
|
||||
fn resolveNode(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
@@ -196,8 +262,8 @@ fn resolveNode(cmd: anytype) !void {
|
||||
var scope = page.scope;
|
||||
if (params.executionContextId) |context_id| {
|
||||
if (scope.context.debugContextId() != context_id) {
|
||||
const isolated_world = bc.isolated_world orelse return error.ContextNotFound;
|
||||
scope = isolated_world.scope orelse return error.ContextNotFound;
|
||||
var isolated_world = bc.isolated_world orelse return error.ContextNotFound;
|
||||
scope = &(isolated_world.executor.scope orelse return error.ContextNotFound);
|
||||
|
||||
if (scope.context.debugContextId() != context_id) return error.ContextNotFound;
|
||||
}
|
||||
@@ -233,23 +299,136 @@ fn describeNode(cmd: anytype) !void {
|
||||
depth: u32 = 1,
|
||||
pierce: bool = false,
|
||||
})) orelse return error.InvalidParams;
|
||||
if (params.backendNodeId != null or params.depth != 1 or params.pierce) {
|
||||
return error.NotYetImplementedParams;
|
||||
|
||||
if (params.depth != 1 or params.pierce) return error.NotYetImplementedParams;
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
|
||||
|
||||
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{});
|
||||
}
|
||||
|
||||
// An array of quad vertices, x immediately followed by y for each point, points clock-wise.
|
||||
// Note Y points downward
|
||||
// We are assuming the start/endpoint is not repeated.
|
||||
const Quad = [8]f64;
|
||||
|
||||
const BoxModel = struct {
|
||||
content: Quad,
|
||||
padding: Quad,
|
||||
border: Quad,
|
||||
margin: Quad,
|
||||
width: i32,
|
||||
height: i32,
|
||||
// shapeOutside: ?ShapeOutsideInfo,
|
||||
};
|
||||
|
||||
fn rectToQuad(rect: Element.DOMRect) Quad {
|
||||
return Quad{
|
||||
rect.x,
|
||||
rect.y,
|
||||
rect.x + rect.width,
|
||||
rect.y,
|
||||
rect.x + rect.width,
|
||||
rect.y + rect.height,
|
||||
rect.x,
|
||||
rect.y + rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
fn scrollIntoViewIfNeeded(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?u32 = null,
|
||||
objectId: ?[]const u8 = null,
|
||||
rect: ?Element.DOMRect = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
// Only 1 of nodeId, backendNodeId, objectId may be set, but chrome just takes the first non-null
|
||||
|
||||
// We retrieve the node to at least check if it exists and is valid.
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
|
||||
|
||||
const node_type = parser.nodeType(node._node) catch return error.InvalidNode;
|
||||
switch (node_type) {
|
||||
.element => {},
|
||||
.document => {},
|
||||
.text => {},
|
||||
else => return error.NodeDoesNotHaveGeometry,
|
||||
}
|
||||
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn getNode(arena: Allocator, browser_context: anytype, node_id: ?Node.Id, backend_node_id: ?Node.Id, object_id: ?[]const u8) !*Node {
|
||||
const input_node_id = node_id orelse backend_node_id;
|
||||
if (input_node_id) |input_node_id_| {
|
||||
return browser_context.node_registry.lookup_by_id.get(input_node_id_) orelse return error.NodeNotFound;
|
||||
}
|
||||
if (object_id) |object_id_| {
|
||||
// Retrieve the object from which ever context it is in.
|
||||
const parser_node = try browser_context.inspector.getNodePtr(arena, object_id_);
|
||||
return try browser_context.node_registry.register(@ptrCast(parser_node));
|
||||
}
|
||||
return error.MissingParams;
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getContentQuads
|
||||
// Related to: https://drafts.csswg.org/cssom-view/#the-geometryutils-interface
|
||||
fn getContentQuads(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
objectId: ?[]const u8 = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
if (params.nodeId != null) {
|
||||
const node = bc.node_registry.lookup_by_id.get(params.nodeId.?) orelse return error.NodeNotFound;
|
||||
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{});
|
||||
}
|
||||
if (params.objectId != null) {
|
||||
// Retrieve the object from which ever context it is in.
|
||||
const parser_node = try bc.inspector.getNodePtr(cmd.arena, params.objectId.?);
|
||||
const node = try bc.node_registry.register(@ptrCast(parser_node));
|
||||
return cmd.sendResult(.{ .node = bc.nodeWriter(node, .{}) }, .{});
|
||||
}
|
||||
return error.MissingParams;
|
||||
const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
|
||||
|
||||
// TODO likely if the following CSS properties are set the quads should be empty
|
||||
// visibility: hidden
|
||||
// display: none
|
||||
|
||||
if (try parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement;
|
||||
// TODO implement for document or text
|
||||
// Most likely document would require some hierachgy in the renderer. It is left unimplemented till we have a good example.
|
||||
// Text may be tricky, multiple quads in case of multiple lines? empty quads of text = ""?
|
||||
// Elements like SVGElement may have multiple quads.
|
||||
|
||||
const element = parser.nodeToElement(node._node);
|
||||
const rect = try Element._getBoundingClientRect(element, &bc.session.page.?.state);
|
||||
const quad = rectToQuad(rect);
|
||||
|
||||
return cmd.sendResult(.{ .quads = &.{quad} }, .{});
|
||||
}
|
||||
|
||||
fn getBoxModel(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?u32 = null,
|
||||
objectId: ?[]const u8 = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const node = try getNode(cmd.arena, bc, params.nodeId, params.backendNodeId, params.objectId);
|
||||
|
||||
// TODO implement for document or text
|
||||
if (try parser.nodeType(node._node) != .element) return error.NodeIsNotAnElement;
|
||||
const element = parser.nodeToElement(node._node);
|
||||
|
||||
const rect = try Element._getBoundingClientRect(element, &bc.session.page.?.state);
|
||||
const quad = rectToQuad(rect);
|
||||
|
||||
return cmd.sendResult(.{ .model = BoxModel{
|
||||
.content = quad,
|
||||
.padding = quad,
|
||||
.border = quad,
|
||||
.margin = quad,
|
||||
.width = @intFromFloat(rect.width),
|
||||
.height = @intFromFloat(rect.height),
|
||||
} }, .{});
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
@@ -318,3 +497,111 @@ test "cdp.dom: search flow" {
|
||||
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
|
||||
}));
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector unknown search id" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<p>1</p> <p>2</p>" });
|
||||
|
||||
try testing.expectError(error.UnknownNode, ctx.processMessage(.{
|
||||
.id = 9,
|
||||
.method = "DOM.querySelector",
|
||||
.params = .{ .nodeId = 99, .selector = "" },
|
||||
}));
|
||||
try testing.expectError(error.UnknownNode, ctx.processMessage(.{
|
||||
.id = 9,
|
||||
.method = "DOM.querySelectorAll",
|
||||
.params = .{ .nodeId = 99, .selector = "" },
|
||||
}));
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector Node not found" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<p>1</p> <p>2</p>" });
|
||||
|
||||
try ctx.processMessage(.{ // Hacky way to make sure nodeId 0 exists in the registry
|
||||
.id = 3,
|
||||
.method = "DOM.performSearch",
|
||||
.params = .{ .query = "p" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 3 });
|
||||
|
||||
try testing.expectError(error.NodeNotFoundForGivenId, ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "DOM.querySelector",
|
||||
.params = .{ .nodeId = 0, .selector = "a" },
|
||||
}));
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "DOM.querySelectorAll",
|
||||
.params = .{ .nodeId = 0, .selector = "a" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeIds = &[_]u32{} }, .{ .id = 5 });
|
||||
}
|
||||
|
||||
test "cdp.dom: querySelector Nodes found" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<div><p>2</p></div>" });
|
||||
|
||||
try ctx.processMessage(.{ // Hacky way to make sure nodeId 0 exists in the registry
|
||||
.id = 3,
|
||||
.method = "DOM.performSearch",
|
||||
.params = .{ .query = "div" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 1 }, .{ .id = 3 });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "DOM.querySelector",
|
||||
.params = .{ .nodeId = 0, .selector = "p" },
|
||||
});
|
||||
try ctx.expectSentEvent("DOM.setChildNodes", null, .{});
|
||||
try ctx.expectSentResult(.{ .nodeId = 5 }, .{ .id = 4 });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "DOM.querySelectorAll",
|
||||
.params = .{ .nodeId = 0, .selector = "p" },
|
||||
});
|
||||
try ctx.expectSentEvent("DOM.setChildNodes", null, .{});
|
||||
try ctx.expectSentResult(.{ .nodeIds = &.{5} }, .{ .id = 5 });
|
||||
}
|
||||
|
||||
test "cdp.dom: getBoxModel" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<div><p>2</p></div>" });
|
||||
|
||||
try ctx.processMessage(.{ // Hacky way to make sure nodeId 0 exists in the registry
|
||||
.id = 3,
|
||||
.method = "DOM.getDocument",
|
||||
});
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "DOM.querySelector",
|
||||
.params = .{ .nodeId = 0, .selector = "p" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeId = 2 }, .{ .id = 4 });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "DOM.getBoxModel",
|
||||
.params = .{ .nodeId = 5 },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .model = BoxModel{
|
||||
.content = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
|
||||
.padding = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
|
||||
.border = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
|
||||
.margin = Quad{ 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0 },
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
} }, .{ .id = 5 });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Page = @import("../../browser/browser.zig").Page;
|
||||
const Page = @import("../../browser/page.zig").Page;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
|
||||
@@ -17,15 +17,130 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Notification = @import("../../notification.zig").Notification;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
disable,
|
||||
setCacheDisabled,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.enable => return enable(cmd),
|
||||
.disable => return disable(cmd),
|
||||
.setCacheDisabled => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
|
||||
fn enable(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
try bc.networkEnable();
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn disable(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
bc.networkDisable();
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
pub fn httpRequestStart(arena: Allocator, bc: anytype, request: *const Notification.RequestStart) !void {
|
||||
// Isn't possible to do a network request within a Browser (which our
|
||||
// notification is tied to), without a page.
|
||||
std.debug.assert(bc.session.page != null);
|
||||
|
||||
var cdp = bc.cdp;
|
||||
|
||||
// all unreachable because we _have_ to have a page.
|
||||
const session_id = bc.session_id orelse unreachable;
|
||||
const target_id = bc.target_id orelse unreachable;
|
||||
const page = bc.session.currentPage() orelse unreachable;
|
||||
|
||||
const document_url = try urlToString(arena, &page.url.uri, .{
|
||||
.scheme = true,
|
||||
.authentication = true,
|
||||
.authority = true,
|
||||
.path = true,
|
||||
.query = true,
|
||||
});
|
||||
|
||||
const request_url = try urlToString(arena, request.url, .{
|
||||
.scheme = true,
|
||||
.authentication = true,
|
||||
.authority = true,
|
||||
.path = true,
|
||||
.query = true,
|
||||
});
|
||||
|
||||
const request_fragment = try urlToString(arena, request.url, .{
|
||||
.fragment = true,
|
||||
});
|
||||
|
||||
var headers: std.StringArrayHashMapUnmanaged([]const u8) = .empty;
|
||||
try headers.ensureTotalCapacity(arena, request.headers.len);
|
||||
for (request.headers) |header| {
|
||||
headers.putAssumeCapacity(header.name, header.value);
|
||||
}
|
||||
|
||||
// We're missing a bunch of fields, but, for now, this seems like enough
|
||||
try cdp.sendEvent("Network.requestWillBeSent", .{
|
||||
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{request.id}),
|
||||
.frameId = target_id,
|
||||
.loaderId = bc.loader_id,
|
||||
.documentUrl = document_url,
|
||||
.request = .{
|
||||
.url = request_url,
|
||||
.urlFragment = request_fragment,
|
||||
.method = @tagName(request.method),
|
||||
.hasPostData = request.has_body,
|
||||
.headers = std.json.ArrayHashMap([]const u8){ .map = headers },
|
||||
},
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
pub fn httpRequestComplete(arena: Allocator, bc: anytype, request: *const Notification.RequestComplete) !void {
|
||||
// Isn't possible to do a network request within a Browser (which our
|
||||
// notification is tied to), without a page.
|
||||
std.debug.assert(bc.session.page != null);
|
||||
|
||||
var cdp = bc.cdp;
|
||||
|
||||
// all unreachable because we _have_ to have a page.
|
||||
const session_id = bc.session_id orelse unreachable;
|
||||
const target_id = bc.target_id orelse unreachable;
|
||||
|
||||
const url = try urlToString(arena, request.url, .{
|
||||
.scheme = true,
|
||||
.authentication = true,
|
||||
.authority = true,
|
||||
.path = true,
|
||||
.query = true,
|
||||
});
|
||||
|
||||
var headers: std.StringArrayHashMapUnmanaged([]const u8) = .empty;
|
||||
try headers.ensureTotalCapacity(arena, request.headers.len);
|
||||
for (request.headers) |header| {
|
||||
headers.putAssumeCapacity(header.name, header.value);
|
||||
}
|
||||
|
||||
// We're missing a bunch of fields, but, for now, this seems like enough
|
||||
try cdp.sendEvent("Network.responseReceived", .{
|
||||
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{request.id}),
|
||||
.frameId = target_id,
|
||||
.loaderId = bc.loader_id,
|
||||
.response = .{
|
||||
.url = url,
|
||||
.status = request.status,
|
||||
.headers = std.json.ArrayHashMap([]const u8){ .map = headers },
|
||||
},
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
fn urlToString(arena: Allocator, url: *const std.Uri, opts: std.Uri.WriteToStreamOptions) ![]const u8 {
|
||||
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try url.writeToStream(opts, buf.writer(arena));
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
|
||||
const std = @import("std");
|
||||
const URL = @import("../../url.zig").URL;
|
||||
const Page = @import("../../browser/page.zig").Page;
|
||||
const Notification = @import("../../notification.zig").Notification;
|
||||
const Page = @import("../../browser/browser.zig").Page;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
@@ -115,7 +117,7 @@ fn createIsolatedWorld(cmd: anytype) !void {
|
||||
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
try pageCreated(bc, page);
|
||||
const scope = world.scope.?;
|
||||
const scope = &world.executor.scope.?;
|
||||
|
||||
// Create the auxdata json for the contextCreated event
|
||||
// Calling contextCreated will assign a Id to the context and send the contextCreated event
|
||||
@@ -137,7 +139,7 @@ fn navigate(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
// didn't create?
|
||||
const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
|
||||
// const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
|
||||
|
||||
// didn't attach?
|
||||
if (bc.session_id == null) {
|
||||
@@ -148,17 +150,14 @@ fn navigate(cmd: anytype) !void {
|
||||
|
||||
var page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
bc.loader_id = bc.cdp.loader_id_gen.next();
|
||||
try cmd.sendResult(.{
|
||||
.frameId = target_id,
|
||||
.loaderId = bc.loader_id,
|
||||
}, .{});
|
||||
|
||||
try page.navigate(url, .{
|
||||
.reason = .address_bar,
|
||||
.cdp_id = cmd.input.id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void {
|
||||
pub fn pageNavigate(arena: Allocator, bc: anytype, event: *const Notification.PageNavigate) !void {
|
||||
// I don't think it's possible that we get these notifications and don't
|
||||
// have these things setup.
|
||||
std.debug.assert(bc.session.page != null);
|
||||
@@ -170,7 +169,8 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void
|
||||
|
||||
bc.reset();
|
||||
|
||||
if (event.reason == .anchor) {
|
||||
const is_anchor = event.opts.reason == .anchor;
|
||||
if (is_anchor) {
|
||||
try cdp.sendEvent("Page.frameScheduledNavigation", .{
|
||||
.frameId = target_id,
|
||||
.delay = 0,
|
||||
@@ -199,6 +199,22 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void
|
||||
.frameId = target_id,
|
||||
}, .{ .session_id = session_id });
|
||||
|
||||
// Drivers are sensitive to the order of events. Some more than others.
|
||||
// The result for the Page.navigate seems like it _must_ come after
|
||||
// the frameStartedLoading, but before any lifecycleEvent. So we
|
||||
// unfortunately have to put the input_id ito the NavigateOpts which gets
|
||||
// passed back into the notification.
|
||||
if (event.opts.cdp_id) |input_id| {
|
||||
try cdp.sendJSON(.{
|
||||
.id = input_id,
|
||||
.result = .{
|
||||
.frameId = target_id,
|
||||
.loaderId = loader_id,
|
||||
},
|
||||
.sessionId = session_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (bc.page_life_cycle_events) {
|
||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||
.name = "init",
|
||||
@@ -208,7 +224,7 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
if (event.reason == .anchor) {
|
||||
if (is_anchor) {
|
||||
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
|
||||
.frameId = target_id,
|
||||
}, .{ .session_id = session_id });
|
||||
@@ -219,24 +235,22 @@ pub fn pageNavigate(bc: anytype, event: *const Notification.PageNavigate) !void
|
||||
// The client will expect us to send new contextCreated events, such that the client has new id's for the active contexts.
|
||||
try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
|
||||
|
||||
var buffer: [512]u8 = undefined;
|
||||
{
|
||||
var fba = std.heap.FixedBufferAllocator.init(&buffer);
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const aux_data = try std.fmt.allocPrint(fba.allocator(), "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
|
||||
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
|
||||
bc.inspector.contextCreated(
|
||||
page.scope,
|
||||
"",
|
||||
try page.origin(fba.allocator()),
|
||||
try page.origin(arena),
|
||||
aux_data,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (bc.isolated_world) |*isolated_world| {
|
||||
const aux_json = try std.fmt.bufPrint(&buffer, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{target_id});
|
||||
const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{target_id});
|
||||
// Calling contextCreated will assign a new Id to the context and send the contextCreated event
|
||||
bc.inspector.contextCreated(
|
||||
isolated_world.scope.?,
|
||||
&isolated_world.executor.scope.?,
|
||||
isolated_world.name,
|
||||
"://",
|
||||
aux_json,
|
||||
@@ -258,7 +272,7 @@ pub fn pageCreated(bc: anytype, page: *Page) !void {
|
||||
try isolated_world.createContext(page);
|
||||
|
||||
const polyfill = @import("../../browser/polyfill/polyfill.zig");
|
||||
try polyfill.load(bc.arena, isolated_world.scope.?);
|
||||
try polyfill.load(bc.arena, &isolated_world.executor.scope.?);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const parser = @import("../browser/netsurf.zig");
|
||||
const base = @import("../testing.zig");
|
||||
pub const allocator = base.allocator;
|
||||
pub const expectJson = base.expectJson;
|
||||
pub const expect = std.testing.expect;
|
||||
pub const expectEqual = base.expectEqual;
|
||||
pub const expectError = base.expectError;
|
||||
pub const expectEqualSlices = base.expectEqualSlices;
|
||||
@@ -121,9 +122,8 @@ const TestContext = struct {
|
||||
if (opts.html) |html| {
|
||||
if (bc.session_id == null) bc.session_id = "SID-X";
|
||||
parser.deinit();
|
||||
try parser.init();
|
||||
const page = try bc.session.createPage();
|
||||
page.doc = (try Document.init(html)).doc;
|
||||
page.window.document = (try Document.init(html)).doc;
|
||||
}
|
||||
return bc;
|
||||
}
|
||||
|
||||
1066
src/http/client.zig
1066
src/http/client.zig
File diff suppressed because it is too large
Load Diff
174
src/main.zig
174
src/main.zig
@@ -25,14 +25,14 @@ const App = @import("app.zig").App;
|
||||
const Platform = @import("runtime/js.zig").Platform;
|
||||
const Browser = @import("browser/browser.zig").Browser;
|
||||
|
||||
const build_config = @import("build_config");
|
||||
const parser = @import("browser/netsurf.zig");
|
||||
const version = @import("build_info").git_commit;
|
||||
|
||||
const log = std.log.scoped(.cli);
|
||||
|
||||
pub const std_options = std.Options{
|
||||
// Set the log level to info
|
||||
.log_level = .info,
|
||||
.log_level = @enumFromInt(@intFromEnum(build_config.log_level)),
|
||||
|
||||
// Define logFn to override the std implementation
|
||||
.logFn = logFn,
|
||||
@@ -59,7 +59,7 @@ pub fn main() !void {
|
||||
return std.process.cleanExit();
|
||||
},
|
||||
.version => {
|
||||
std.debug.print("{s}\n", .{version});
|
||||
std.debug.print("{s}\n", .{build_config.git_commit});
|
||||
return std.process.cleanExit();
|
||||
},
|
||||
else => {},
|
||||
@@ -70,7 +70,7 @@ pub fn main() !void {
|
||||
|
||||
var app = try App.init(alloc, .{
|
||||
.run_mode = args.mode,
|
||||
.gc_hints = args.gcHints(),
|
||||
.http_proxy = args.httpProxy(),
|
||||
.tls_verify_host = args.tlsVerifyHost(),
|
||||
});
|
||||
defer app.deinit();
|
||||
@@ -128,18 +128,17 @@ const Command = struct {
|
||||
mode: Mode,
|
||||
exec_name: []const u8,
|
||||
|
||||
fn gcHints(self: *const Command) bool {
|
||||
fn tlsVerifyHost(self: *const Command) bool {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.gc_hints,
|
||||
else => false,
|
||||
inline .serve, .fetch => |opts| opts.tls_verify_host,
|
||||
else => true,
|
||||
};
|
||||
}
|
||||
|
||||
fn tlsVerifyHost(self: *const Command) bool {
|
||||
fn httpProxy(self: *const Command) ?std.Uri {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.tls_verify_host,
|
||||
.fetch => |opts| opts.tls_verify_host,
|
||||
else => true,
|
||||
inline .serve, .fetch => |opts| opts.http_proxy,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,14 +153,15 @@ const Command = struct {
|
||||
host: []const u8,
|
||||
port: u16,
|
||||
timeout: u16,
|
||||
gc_hints: bool,
|
||||
tls_verify_host: bool,
|
||||
http_proxy: ?std.Uri,
|
||||
};
|
||||
|
||||
const Fetch = struct {
|
||||
url: []const u8,
|
||||
dump: bool = false,
|
||||
tls_verify_host: bool,
|
||||
http_proxy: ?std.Uri,
|
||||
};
|
||||
|
||||
fn printUsageAndExit(self: *const Command, success: bool) void {
|
||||
@@ -184,6 +184,9 @@ const Command = struct {
|
||||
\\ set if you understand and accept the risk of
|
||||
\\ disabling host verification.
|
||||
\\
|
||||
\\--http_proxy The HTTP proxy to use for all HTTP requests.
|
||||
\\ Defaults to none.
|
||||
\\
|
||||
\\serve command
|
||||
\\Starts a websocket CDP server
|
||||
\\Example: {s} serve --host 127.0.0.1 --port 9222
|
||||
@@ -198,15 +201,15 @@ const Command = struct {
|
||||
\\--timeout Inactivity timeout in seconds before disconnecting clients
|
||||
\\ Defaults to 3 (seconds)
|
||||
\\
|
||||
\\--gc_hints Encourage V8 to cleanup garbage for each new browser context.
|
||||
\\ Defaults to false
|
||||
\\
|
||||
\\--insecure_disable_tls_host_verification
|
||||
\\ Disables host verification on all HTTP requests.
|
||||
\\ This is an advanced option which should only be
|
||||
\\ set if you understand and accept the risk of
|
||||
\\ disabling host verification.
|
||||
\\
|
||||
\\--http_proxy The HTTP proxy to use for all HTTP requests.
|
||||
\\ Defaults to none.
|
||||
\\
|
||||
\\version command
|
||||
\\Displays the version of {s}
|
||||
\\
|
||||
@@ -281,10 +284,6 @@ fn inferMode(opt: []const u8) ?App.RunMode {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--gc_hints")) {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -295,14 +294,14 @@ fn parseServeArgs(
|
||||
var host: []const u8 = "127.0.0.1";
|
||||
var port: u16 = 9222;
|
||||
var timeout: u16 = 3;
|
||||
var gc_hints = false;
|
||||
var tls_verify_host = true;
|
||||
var http_proxy: ?std.Uri = null;
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (std.mem.eql(u8, "--host", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.err("--host argument requires an value", .{});
|
||||
return error.InvalidMissingHost;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
host = try allocator.dupe(u8, str);
|
||||
continue;
|
||||
@@ -311,12 +310,12 @@ fn parseServeArgs(
|
||||
if (std.mem.eql(u8, "--port", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.err("--port argument requires an value", .{});
|
||||
return error.InvalidMissingPort;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
port = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.err("--port value is invalid: {}", .{err});
|
||||
return error.InvalidPort;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -324,12 +323,12 @@ fn parseServeArgs(
|
||||
if (std.mem.eql(u8, "--timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.err("--timeout argument requires an value", .{});
|
||||
return error.MissingTimeout;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
timeout = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.err("--timeout value is invalid: {}", .{err});
|
||||
return error.InvalidTimeout;
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -339,8 +338,12 @@ fn parseServeArgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--gc_hints", opt)) {
|
||||
gc_hints = true;
|
||||
if (std.mem.eql(u8, "--http_proxy", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.err("--http_proxy argument requires an value", .{});
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
http_proxy = try std.Uri.parse(try allocator.dupe(u8, str));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -352,7 +355,7 @@ fn parseServeArgs(
|
||||
.host = host,
|
||||
.port = port,
|
||||
.timeout = timeout,
|
||||
.gc_hints = gc_hints,
|
||||
.http_proxy = http_proxy,
|
||||
.tls_verify_host = tls_verify_host,
|
||||
};
|
||||
}
|
||||
@@ -364,6 +367,7 @@ fn parseFetchArgs(
|
||||
var dump: bool = false;
|
||||
var url: ?[]const u8 = null;
|
||||
var tls_verify_host = true;
|
||||
var http_proxy: ?std.Uri = null;
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (std.mem.eql(u8, "--dump", opt)) {
|
||||
@@ -376,6 +380,15 @@ fn parseFetchArgs(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_proxy", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.err("--http_proxy argument requires an value", .{});
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
http_proxy = try std.Uri.parse(try allocator.dupe(u8, str));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, opt, "--")) {
|
||||
log.err("Unknown option to serve command: '{s}'", .{opt});
|
||||
return error.UnkownOption;
|
||||
@@ -396,6 +409,7 @@ fn parseFetchArgs(
|
||||
return .{
|
||||
.url = url.?,
|
||||
.dump = dump,
|
||||
.http_proxy = http_proxy,
|
||||
.tls_verify_host = tls_verify_host,
|
||||
};
|
||||
}
|
||||
@@ -470,51 +484,57 @@ fn serveHTTP(address: std.net.Address) !void {
|
||||
defer conn.stream.close();
|
||||
var http_server = std.http.Server.init(conn, &read_buffer);
|
||||
|
||||
while (http_server.state == .ready) {
|
||||
var request = http_server.receiveHead() catch |err| switch (err) {
|
||||
error.HttpConnectionClosing => continue :ACCEPT,
|
||||
else => {
|
||||
std.debug.print("Test HTTP Server error: {}\n", .{err});
|
||||
return err;
|
||||
var request = http_server.receiveHead() catch |err| switch (err) {
|
||||
error.HttpConnectionClosing => continue :ACCEPT,
|
||||
else => {
|
||||
std.debug.print("Test HTTP Server error: {}\n", .{err});
|
||||
return err;
|
||||
},
|
||||
};
|
||||
|
||||
const path = request.head.target;
|
||||
if (std.mem.eql(u8, path, "/loader")) {
|
||||
try request.respond("Hello!", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
|
||||
try request.respond("", .{
|
||||
.extra_headers = &.{.{ .name = "Connection", .value = "close" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Connection", .value = "close" },
|
||||
.{ .name = "LOCATION", .value = "../http_client/echo" },
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
|
||||
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
|
||||
try request.respond(body, .{
|
||||
.extra_headers = &.{ .{ .name = "Connection", .value = "close" }, .{ .name = "Content-Encoding", .value = "gzip" } },
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
|
||||
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
|
||||
|
||||
const path = request.head.target;
|
||||
if (std.mem.eql(u8, path, "/loader")) {
|
||||
try request.respond("Hello!", .{});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/simple")) {
|
||||
try request.respond("", .{});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{.{ .name = "LOCATION", .value = "../http_client/echo" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect/secure")) {
|
||||
try request.respond("", .{
|
||||
.status = .moved_permanently,
|
||||
.extra_headers = &.{.{ .name = "LOCATION", .value = "https://127.0.0.1:9581/http_client/body" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/gzip")) {
|
||||
const body = &.{ 0x1f, 0x8b, 0x08, 0x08, 0x01, 0xc6, 0x19, 0x68, 0x00, 0x03, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x68, 0x74, 0x6d, 0x6c, 0x00, 0x73, 0x54, 0xc8, 0x4b, 0x2d, 0x57, 0x48, 0x2a, 0xca, 0x2f, 0x2f, 0x4e, 0x2d, 0x52, 0x48, 0x2a, 0xcd, 0xcc, 0x29, 0x51, 0x48, 0xcb, 0x2f, 0x52, 0xc8, 0x4d, 0x4c, 0xce, 0xc8, 0xcc, 0x4b, 0x2d, 0xe6, 0x02, 0x00, 0xe7, 0xc3, 0x4b, 0x27, 0x21, 0x00, 0x00, 0x00 };
|
||||
try request.respond(body, .{
|
||||
.extra_headers = &.{.{ .name = "Content-Encoding", .value = "gzip" }},
|
||||
});
|
||||
} else if (std.mem.eql(u8, path, "/http_client/echo")) {
|
||||
var headers: std.ArrayListUnmanaged(std.http.Header) = .{};
|
||||
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |hdr| {
|
||||
try headers.append(aa, .{
|
||||
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
|
||||
.value = hdr.value,
|
||||
});
|
||||
}
|
||||
|
||||
try request.respond("over 9000!", .{
|
||||
.status = .created,
|
||||
.extra_headers = headers.items,
|
||||
var it = request.iterateHeaders();
|
||||
while (it.next()) |hdr| {
|
||||
try headers.append(aa, .{
|
||||
.name = try std.fmt.allocPrint(aa, "_{s}", .{hdr.name}),
|
||||
.value = hdr.value,
|
||||
});
|
||||
}
|
||||
try headers.append(aa, .{ .name = "Connection", .value = "Close" });
|
||||
|
||||
try request.respond("over 9000!", .{
|
||||
.status = .created,
|
||||
.extra_headers = headers.items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,9 +548,6 @@ fn serveHTTPS(address: std.net.Address) !void {
|
||||
var listener = try address.listen(.{ .reuse_address = true });
|
||||
defer listener.deinit();
|
||||
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
test_wg.finish();
|
||||
|
||||
var seed: u64 = undefined;
|
||||
@@ -540,9 +557,6 @@ fn serveHTTPS(address: std.net.Address) !void {
|
||||
|
||||
var read_buffer: [1024]u8 = undefined;
|
||||
while (true) {
|
||||
// defer _ = arena.reset(.{ .retain_with_limit = 1024 });
|
||||
// const aa = arena.allocator();
|
||||
|
||||
const stream = blk: {
|
||||
const conn = try listener.accept();
|
||||
break :blk conn.stream;
|
||||
@@ -570,17 +584,17 @@ fn serveHTTPS(address: std.net.Address) !void {
|
||||
var response: []const u8 = undefined;
|
||||
if (std.mem.eql(u8, path, "/http_client/simple")) {
|
||||
fragment = true;
|
||||
response = "HTTP/1.1 200 \r\nContent-Length: 0\r\n\r\n";
|
||||
response = "HTTP/1.1 200 \r\nContent-Length: 0\r\nConnection: Close\r\n\r\n";
|
||||
} else if (std.mem.eql(u8, path, "/http_client/body")) {
|
||||
fragment = true;
|
||||
response = "HTTP/1.1 201 CREATED\r\nContent-Length: 20\r\n Another : HEaDer \r\n\r\n1234567890abcdefhijk";
|
||||
response = "HTTP/1.1 201 CREATED\r\nContent-Length: 20\r\nConnection: Close\r\n Another : HEaDer \r\n\r\n1234567890abcdefhijk";
|
||||
} else if (std.mem.eql(u8, path, "/http_client/redirect/insecure")) {
|
||||
fragment = true;
|
||||
response = "HTTP/1.1 307 GOTO\r\nLocation: http://127.0.0.1:9582/http_client/redirect\r\n\r\n";
|
||||
response = "HTTP/1.1 307 GOTO\r\nLocation: http://127.0.0.1:9582/http_client/redirect\r\nConnection: Close\r\n\r\n";
|
||||
} else if (std.mem.eql(u8, path, "/xhr")) {
|
||||
response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 100\r\n\r\n" ++ ("1234567890" ** 10);
|
||||
response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: 100\r\nConnection: Close\r\n\r\n" ++ ("1234567890" ** 10);
|
||||
} else if (std.mem.eql(u8, path, "/xhr/json")) {
|
||||
response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 18\r\n\r\n{\"over\":\"9000!!!\"}";
|
||||
response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 18\r\nConnection: Close\r\n\r\n{\"over\":\"9000!!!\"}";
|
||||
} else {
|
||||
// should not have an unknown path
|
||||
unreachable;
|
||||
|
||||
@@ -116,7 +116,7 @@ fn run(arena: Allocator, test_file: []const u8, loader: *FileLoader, err_out: *?
|
||||
try polyfill.load(arena, runner.scope);
|
||||
|
||||
// loop over the scripts.
|
||||
const doc = parser.documentHTMLToDocument(runner.state.document.?);
|
||||
const doc = parser.documentHTMLToDocument(runner.state.window.document);
|
||||
const scripts = try parser.documentGetElementsByTagName(doc, "script");
|
||||
const script_count = try parser.nodeListLength(scripts);
|
||||
for (0..script_count) |i| {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const std = @import("std");
|
||||
|
||||
const URL = @import("url.zig").URL;
|
||||
const browser = @import("browser/browser.zig");
|
||||
const page = @import("browser/page.zig");
|
||||
const http_client = @import("http/client.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
@@ -59,14 +60,18 @@ pub const Notification = struct {
|
||||
page_created: List = .{},
|
||||
page_navigate: List = .{},
|
||||
page_navigated: List = .{},
|
||||
http_request_start: List = .{},
|
||||
http_request_complete: List = .{},
|
||||
notification_created: List = .{},
|
||||
};
|
||||
|
||||
const Events = union(enum) {
|
||||
page_remove: PageRemove,
|
||||
page_created: *browser.Page,
|
||||
page_created: *page.Page,
|
||||
page_navigate: *const PageNavigate,
|
||||
page_navigated: *const PageNavigated,
|
||||
http_request_start: *const RequestStart,
|
||||
http_request_complete: *const RequestComplete,
|
||||
notification_created: *Notification,
|
||||
};
|
||||
const EventType = std.meta.FieldEnum(Events);
|
||||
@@ -76,7 +81,7 @@ pub const Notification = struct {
|
||||
pub const PageNavigate = struct {
|
||||
timestamp: u32,
|
||||
url: *const URL,
|
||||
reason: browser.NavigateReason,
|
||||
opts: page.NavigateOpts,
|
||||
};
|
||||
|
||||
pub const PageNavigated = struct {
|
||||
@@ -84,6 +89,21 @@ pub const Notification = struct {
|
||||
url: *const URL,
|
||||
};
|
||||
|
||||
pub const RequestStart = struct {
|
||||
id: usize,
|
||||
url: *const std.Uri,
|
||||
method: http_client.Request.Method,
|
||||
headers: []std.http.Header,
|
||||
has_body: bool,
|
||||
};
|
||||
|
||||
pub const RequestComplete = struct {
|
||||
id: usize,
|
||||
url: *const std.Uri,
|
||||
status: u16,
|
||||
headers: []http_client.Header,
|
||||
};
|
||||
|
||||
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
|
||||
@@ -128,6 +148,7 @@ pub const Notification = struct {
|
||||
.list = list,
|
||||
.func = @ptrCast(func),
|
||||
.receiver = receiver,
|
||||
.event = event,
|
||||
.struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
|
||||
};
|
||||
|
||||
@@ -143,6 +164,30 @@ pub const Notification = struct {
|
||||
list.append(node);
|
||||
}
|
||||
|
||||
pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void {
|
||||
var nodes = self.listeners.getPtr(@intFromPtr(receiver)) orelse return;
|
||||
|
||||
const node_pool = &self.node_pool;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < nodes.items.len) {
|
||||
const node = nodes.items[i];
|
||||
if (node.data.event != event) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
node.data.list.remove(node);
|
||||
node_pool.destroy(node);
|
||||
_ = nodes.swapRemove(i);
|
||||
}
|
||||
|
||||
if (nodes.items.len == 0) {
|
||||
nodes.deinit(self.allocator);
|
||||
const removed = self.listeners.remove(@intFromPtr(receiver));
|
||||
std.debug.assert(removed == true);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
|
||||
const node_pool = &self.node_pool;
|
||||
|
||||
@@ -184,7 +229,7 @@ fn EventFunc(comptime event: Notification.EventType) type {
|
||||
return *const fn (*anyopaque, ArgType(event)) anyerror!void;
|
||||
}
|
||||
|
||||
// An listener. This is 1 receiver, with its function, and the linked list
|
||||
// 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`
|
||||
@@ -196,6 +241,8 @@ const Listener = struct {
|
||||
// For logging slightly better error
|
||||
struct_name: []const u8,
|
||||
|
||||
event: Notification.EventType,
|
||||
|
||||
// The event list this listener belongs to.
|
||||
// We need this in order to be able to remove the node from the list
|
||||
list: *List,
|
||||
@@ -210,7 +257,7 @@ test "Notification" {
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.timestamp = 4,
|
||||
.url = undefined,
|
||||
.reason = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
|
||||
var tc = TestClient{};
|
||||
@@ -219,7 +266,7 @@ test "Notification" {
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.timestamp = 4,
|
||||
.url = undefined,
|
||||
.reason = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
try testing.expectEqual(4, tc.page_navigate);
|
||||
|
||||
@@ -227,7 +274,7 @@ test "Notification" {
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.timestamp = 10,
|
||||
.url = undefined,
|
||||
.reason = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
try testing.expectEqual(4, tc.page_navigate);
|
||||
|
||||
@@ -236,7 +283,7 @@ test "Notification" {
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.timestamp = 10,
|
||||
.url = undefined,
|
||||
.reason = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
notifier.dispatch(.page_navigated, &.{ .timestamp = 6, .url = undefined });
|
||||
try testing.expectEqual(14, tc.page_navigate);
|
||||
@@ -246,11 +293,40 @@ test "Notification" {
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.timestamp = 100,
|
||||
.url = undefined,
|
||||
.reason = 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 {
|
||||
|
||||
1402
src/runtime/js.zig
1402
src/runtime/js.zig
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,6 @@ const MemoryPool = std.heap.MemoryPool;
|
||||
|
||||
pub const IO = @import("tigerbeetle-io").IO;
|
||||
|
||||
const JSCallback = @import("../browser/env.zig").Env.Callback;
|
||||
|
||||
const log = std.log.scoped(.loop);
|
||||
|
||||
// SingleThreaded I/O Loop based on Tigerbeetle io_uring loop.
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// TODO: use functions instead of "fake" struct once we handle function API generation
|
||||
|
||||
const Runner = testing.Runner(void, void, .{Primitives});
|
||||
const Env = Runner.Env;
|
||||
|
||||
const Primitives = struct {
|
||||
pub fn constructor() Primitives {
|
||||
return .{};
|
||||
@@ -114,6 +118,46 @@ const Primitives = struct {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _returnUint8(_: *const Primitives) Env.TypedArray(u8) {
|
||||
return .{ .values = &.{ 10, 20, 250 } };
|
||||
}
|
||||
|
||||
pub fn _returnInt8(_: *const Primitives) Env.TypedArray(i8) {
|
||||
return .{ .values = &.{ 10, -20, -120 } };
|
||||
}
|
||||
|
||||
pub fn _returnUint16(_: *const Primitives) Env.TypedArray(u16) {
|
||||
return .{ .values = &.{ 10, 200, 2050 } };
|
||||
}
|
||||
|
||||
pub fn _returnInt16(_: *const Primitives) Env.TypedArray(i16) {
|
||||
return .{ .values = &.{ 10, -420, 0 } };
|
||||
}
|
||||
|
||||
pub fn _returnUint32(_: *const Primitives) Env.TypedArray(u32) {
|
||||
return .{ .values = &.{ 10, 2444343, 43432432 } };
|
||||
}
|
||||
|
||||
pub fn _returnInt32(_: *const Primitives) Env.TypedArray(i32) {
|
||||
return .{ .values = &.{ 10, -20, -495929123 } };
|
||||
}
|
||||
|
||||
pub fn _returnUint64(_: *const Primitives) Env.TypedArray(u64) {
|
||||
return .{ .values = &.{ 10, 495812375924, 0 } };
|
||||
}
|
||||
|
||||
pub fn _returnInt64(_: *const Primitives) Env.TypedArray(i64) {
|
||||
return .{ .values = &.{ 10, -49283838122, -2 } };
|
||||
}
|
||||
|
||||
pub fn _returnFloat32(_: *const Primitives) Env.TypedArray(f32) {
|
||||
return .{ .values = &.{ 1.1, -200.035, 0.0003 } };
|
||||
}
|
||||
|
||||
pub fn _returnFloat64(_: *const Primitives) Env.TypedArray(f64) {
|
||||
return .{ .values = &.{ 8881.22284, -4928.3838122, -0.00004 } };
|
||||
}
|
||||
|
||||
pub fn _int16(_: *const Primitives, arr: []i16) void {
|
||||
for (arr) |*a| {
|
||||
a.* -= @intCast(arr.len);
|
||||
@@ -153,7 +197,7 @@ const Primitives = struct {
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
test "JS: primitive types" {
|
||||
var runner = try testing.Runner(void, void, .{Primitives}).init({}, {});
|
||||
var runner = try Runner.init({}, {});
|
||||
defer runner.deinit();
|
||||
|
||||
// constructor
|
||||
@@ -280,5 +324,16 @@ test "JS: primitive types" {
|
||||
.{ "try { p.int64(arr_u64) } catch(e) { e instanceof TypeError; }", "true" },
|
||||
.{ "try { p.intu64(arr_i64) } catch(e) { e instanceof TypeError; }", "true" },
|
||||
.{ "try { p.intu64(arr_u32) } catch(e) { e instanceof TypeError; }", "true" },
|
||||
|
||||
.{ "p.returnUint8()", "10,20,250" },
|
||||
.{ "p.returnInt8()", "10,-20,-120" },
|
||||
.{ "p.returnUint16()", "10,200,2050" },
|
||||
.{ "p.returnInt16()", "10,-420,0" },
|
||||
.{ "p.returnUint32()", "10,2444343,43432432" },
|
||||
.{ "p.returnInt32()", "10,-20,-495929123" },
|
||||
.{ "p.returnUint64()", "10,495812375924,0" },
|
||||
.{ "p.returnInt64()", "10,-49283838122,-2" },
|
||||
.{ "p.returnFloat32()", "1.100000023841858,-200.03500366210938,0.0003000000142492354" },
|
||||
.{ "p.returnFloat64()", "8881.22284,-4928.3838122,-0.00004" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
@@ -26,14 +26,15 @@ pub const allocator = std.testing.allocator;
|
||||
// browser.Env or the browser.SessionState
|
||||
pub fn Runner(comptime State: type, comptime Global: type, comptime types: anytype) type {
|
||||
const AdjustedTypes = if (Global == void) generate.Tuple(.{ types, DefaultGlobal }) else types;
|
||||
const Env = js.Env(State, struct {
|
||||
pub const Interfaces = AdjustedTypes;
|
||||
});
|
||||
|
||||
return struct {
|
||||
env: *Env,
|
||||
scope: *Env.Scope,
|
||||
executor: Env.Executor,
|
||||
executor: Env.ExecutionWorld,
|
||||
|
||||
pub const Env = js.Env(State, struct {
|
||||
pub const Interfaces = AdjustedTypes;
|
||||
});
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -44,7 +45,7 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
|
||||
self.env = try Env.init(allocator, .{});
|
||||
errdefer self.env.deinit();
|
||||
|
||||
self.executor = try self.env.newExecutor();
|
||||
self.executor = try self.env.newExecutionWorld();
|
||||
errdefer self.executor.deinit();
|
||||
|
||||
self.scope = try self.executor.startScope(
|
||||
@@ -63,7 +64,7 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
|
||||
}
|
||||
|
||||
const RunOpts = struct {};
|
||||
pub const Case = std.meta.Tuple(&.{ []const u8, []const u8 });
|
||||
pub const Case = std.meta.Tuple(&.{ []const u8, ?[]const u8 });
|
||||
pub fn testCases(self: *Self, cases: []const Case, _: RunOpts) !void {
|
||||
for (cases, 0..) |case, i| {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
@@ -81,18 +82,22 @@ pub fn Runner(comptime State: type, comptime Global: type, comptime types: anyty
|
||||
return err;
|
||||
};
|
||||
|
||||
const actual = try value.toString(allocator);
|
||||
defer allocator.free(actual);
|
||||
if (std.mem.eql(u8, case.@"1", actual) == false) {
|
||||
std.debug.print("Expected:\n{s}\n\nGot:\n{s}\n\nCase: {d}\n{s}\n", .{ case.@"1", actual, i + 1, case.@"0" });
|
||||
return error.UnexpectedResult;
|
||||
if (case.@"1") |expected| {
|
||||
const actual = try value.toString(allocator);
|
||||
defer allocator.free(actual);
|
||||
if (std.mem.eql(u8, expected, actual) == false) {
|
||||
std.debug.print("Expected:\n{s}\n\nGot:\n{s}\n\nCase: {d}\n{s}\n", .{ expected, actual, i + 1, case.@"0" });
|
||||
return error.UnexpectedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn isExpectedTypeError(expected: []const u8, msg: []const u8) bool {
|
||||
fn isExpectedTypeError(expected_: ?[]const u8, msg: []const u8) bool {
|
||||
const expected = expected_ orelse return false;
|
||||
|
||||
if (!std.mem.eql(u8, expected, "TypeError")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ const MAX_HTTP_REQUEST_SIZE = 2048;
|
||||
// max message size
|
||||
// +14 for max websocket payload overhead
|
||||
// +140 for the max control packet that might be interleaved in a message
|
||||
const MAX_MESSAGE_SIZE = 256 * 1024 + 14;
|
||||
const MAX_MESSAGE_SIZE = 512 * 1024 + 14;
|
||||
|
||||
const Server = struct {
|
||||
app: *App,
|
||||
@@ -1245,11 +1245,8 @@ test "Client: read invalid websocket message" {
|
||||
);
|
||||
}
|
||||
|
||||
// length of message is 0000 0401, i.e: 1024 * 256 + 1
|
||||
try assertWebSocketError(
|
||||
1009,
|
||||
&.{ 129, 255, 0, 0, 0, 0, 0, 4, 0, 1, 'm', 'a', 's', 'k' },
|
||||
);
|
||||
// length of message is 0000 0401, i.e: 1024 * 512 + 1
|
||||
try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 8, 0, 1, 'm', 'a', 's', 'k' });
|
||||
|
||||
// continuation type message must come after a normal message
|
||||
// even when not a fin frame
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const build_info = @import("build_info");
|
||||
const build_config = @import("build_config");
|
||||
|
||||
const Thread = std.Thread;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -151,7 +151,7 @@ const LightPandaEvent = struct {
|
||||
try writer.write(builtin.cpu.arch);
|
||||
|
||||
try writer.objectField("version");
|
||||
try writer.write(build_info.git_commit);
|
||||
try writer.write(build_config.git_commit);
|
||||
|
||||
try writer.objectField("event");
|
||||
try writer.write(@tagName(std.meta.activeTag(self.event)));
|
||||
|
||||
@@ -21,6 +21,7 @@ const Allocator = std.mem.Allocator;
|
||||
|
||||
pub const allocator = std.testing.allocator;
|
||||
pub const expectError = std.testing.expectError;
|
||||
pub const expect = std.testing.expect;
|
||||
pub const expectString = std.testing.expectEqualStrings;
|
||||
pub const expectEqualSlices = std.testing.expectEqualSlices;
|
||||
|
||||
@@ -206,7 +207,7 @@ pub const Random = struct {
|
||||
};
|
||||
|
||||
pub const Document = struct {
|
||||
doc: *parser.Document,
|
||||
doc: *parser.DocumentHTML,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
pub fn init(html: []const u8) !Document {
|
||||
@@ -218,7 +219,7 @@ pub const Document = struct {
|
||||
|
||||
return .{
|
||||
.arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.doc = parser.documentHTMLToDocument(html_doc),
|
||||
.doc = html_doc,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -239,7 +240,7 @@ pub const Document = struct {
|
||||
}
|
||||
|
||||
pub fn asNode(self: *const Document) *parser.Node {
|
||||
return parser.documentToNode(self.doc);
|
||||
return parser.documentHTMLToNode(self.doc);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -370,7 +371,7 @@ pub const JsRunner = struct {
|
||||
const HttpClient = @import("http/client.zig").Client;
|
||||
const storage = @import("browser/storage/storage.zig");
|
||||
const Window = @import("browser/html/window.zig").Window;
|
||||
const Renderer = @import("browser/browser.zig").Renderer;
|
||||
const Renderer = @import("browser/renderer.zig").Renderer;
|
||||
const SessionState = @import("browser/env.zig").SessionState;
|
||||
|
||||
url: URL,
|
||||
@@ -382,7 +383,7 @@ pub const JsRunner = struct {
|
||||
renderer: Renderer,
|
||||
http_client: HttpClient,
|
||||
scope: *Env.Scope,
|
||||
executor: Env.Executor,
|
||||
executor: Env.ExecutionWorld,
|
||||
storage_shelf: storage.Shelf,
|
||||
cookie_jar: storage.CookieJar,
|
||||
|
||||
@@ -411,30 +412,30 @@ pub const JsRunner = struct {
|
||||
var html = std.io.fixedBufferStream(opts.html);
|
||||
const document = try parser.documentHTMLParse(html.reader(), "UTF-8");
|
||||
|
||||
self.state = .{
|
||||
.arena = arena,
|
||||
.loop = &self.loop,
|
||||
.document = document,
|
||||
.url = &self.url,
|
||||
.renderer = &self.renderer,
|
||||
.cookie_jar = &self.cookie_jar,
|
||||
.http_client = &self.http_client,
|
||||
};
|
||||
|
||||
self.window = .{};
|
||||
self.window = try Window.create(null, null);
|
||||
try self.window.replaceDocument(document);
|
||||
try self.window.replaceLocation(.{
|
||||
.url = try self.url.toWebApi(arena),
|
||||
});
|
||||
|
||||
self.storage_shelf = storage.Shelf.init(arena);
|
||||
self.window.setStorageShelf(&self.storage_shelf);
|
||||
|
||||
self.http_client = try HttpClient.init(arena, 1, .{
|
||||
.tls_verify_host = false,
|
||||
});
|
||||
|
||||
self.executor = try self.env.newExecutor();
|
||||
self.state = .{
|
||||
.arena = arena,
|
||||
.loop = &self.loop,
|
||||
.url = &self.url,
|
||||
.window = &self.window,
|
||||
.renderer = &self.renderer,
|
||||
.cookie_jar = &self.cookie_jar,
|
||||
.request_factory = self.http_client.requestFactory(null),
|
||||
};
|
||||
|
||||
self.storage_shelf = storage.Shelf.init(arena);
|
||||
self.window.setStorageShelf(&self.storage_shelf);
|
||||
|
||||
self.executor = try self.env.newExecutionWorld();
|
||||
errdefer self.executor.deinit();
|
||||
|
||||
self.scope = try self.executor.startScope(&self.window, &self.state, {}, true);
|
||||
@@ -454,7 +455,7 @@ pub const JsRunner = struct {
|
||||
}
|
||||
|
||||
const RunOpts = struct {};
|
||||
pub const Case = std.meta.Tuple(&.{ []const u8, []const u8 });
|
||||
pub const Case = std.meta.Tuple(&.{ []const u8, ?[]const u8 });
|
||||
pub fn testCases(self: *JsRunner, cases: []const Case, _: RunOpts) !void {
|
||||
const start = try std.time.Instant.now();
|
||||
|
||||
@@ -472,10 +473,12 @@ pub const JsRunner = struct {
|
||||
try self.loop.run();
|
||||
@import("root").js_runner_duration += std.time.Instant.since(try std.time.Instant.now(), start);
|
||||
|
||||
const actual = try value.toString(self.arena);
|
||||
if (std.mem.eql(u8, case.@"1", actual) == false) {
|
||||
std.debug.print("Expected:\n{s}\n\nGot:\n{s}\n\nCase: {d}\n{s}\n", .{ case.@"1", actual, i + 1, case.@"0" });
|
||||
return error.UnexpectedResult;
|
||||
if (case.@"1") |expected| {
|
||||
const actual = try value.toString(self.arena);
|
||||
if (std.mem.eql(u8, expected, actual) == false) {
|
||||
std.debug.print("Expected:\n{s}\n\nGot:\n{s}\n\nCase: {d}\n{s}\n", .{ expected, actual, i + 1, case.@"0" });
|
||||
return error.UnexpectedResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
src/url.zig
88
src/url.zig
@@ -9,6 +9,7 @@ pub const URL = struct {
|
||||
raw: []const u8,
|
||||
|
||||
pub const empty = URL{ .uri = .{ .scheme = "" }, .raw = "" };
|
||||
pub const about_blank = URL{ .uri = .{ .scheme = "" }, .raw = "about:blank" };
|
||||
|
||||
// We assume str will last as long as the URL
|
||||
// In some cases, this is safe to do, because we know the URL is short lived.
|
||||
@@ -81,4 +82,91 @@ pub const URL = struct {
|
||||
pub fn toWebApi(self: *const URL, allocator: Allocator) !WebApiURL {
|
||||
return WebApiURL.init(allocator, self.uri);
|
||||
}
|
||||
|
||||
/// Properly stitches two URL fragments together.
|
||||
///
|
||||
/// For URLs with a path, it will replace the last entry with the src.
|
||||
/// For URLs without a path, it will add src as the path.
|
||||
pub fn stitch(allocator: std.mem.Allocator, src: []const u8, base: []const u8) ![]const u8 {
|
||||
if (base.len == 0) {
|
||||
return src;
|
||||
}
|
||||
|
||||
const protocol_end: usize = blk: {
|
||||
if (std.mem.indexOf(u8, base, "://")) |protocol_index| {
|
||||
break :blk protocol_index + 3;
|
||||
} else {
|
||||
break :blk 0;
|
||||
}
|
||||
};
|
||||
|
||||
if (std.mem.lastIndexOfScalar(u8, base[protocol_end..], '/')) |index| {
|
||||
const last_slash_pos = index + protocol_end;
|
||||
if (last_slash_pos == base.len - 1) {
|
||||
return std.fmt.allocPrint(allocator, "{s}{s}", .{ base, src });
|
||||
} else {
|
||||
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base[0..last_slash_pos], src });
|
||||
}
|
||||
} else {
|
||||
return std.fmt.allocPrint(allocator, "{s}/{s}", .{ base, src });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "Url resolve size" {
|
||||
const base = "https://www.lightpande.io";
|
||||
const url = try URL.parse(base, null);
|
||||
|
||||
var url_string: [511]u8 = undefined; // Currently this is the largest url we support, it is however recommmended to at least support 2000 characters
|
||||
@memset(&url_string, 'a');
|
||||
|
||||
var buf: [2048]u8 = undefined; // This is approximately the required size to support the current largest supported URL
|
||||
var fba = std.heap.FixedBufferAllocator.init(&buf);
|
||||
const out_url = try url.resolve(fba.allocator(), &url_string);
|
||||
|
||||
try std.testing.expectEqualStrings(out_url.raw[0..25], base);
|
||||
try std.testing.expectEqual(out_url.raw[25], '/');
|
||||
try std.testing.expectEqualStrings(out_url.raw[26..], &url_string);
|
||||
}
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "URL: Stitching Base & Src URLs (Basic)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/xyz/abc/123";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base);
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/xyz/abc/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base & Src URLs (Just Ending Slash)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com/";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base);
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stitching Base & Src URLs (No Ending Slash)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "https://www.google.com";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base);
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("https://www.google.com/something.js", result);
|
||||
}
|
||||
|
||||
test "URL: Stiching Base & Src URLs (Both Local)" {
|
||||
const allocator = testing.allocator;
|
||||
|
||||
const base = "./abcdef/123.js";
|
||||
const src = "something.js";
|
||||
const result = try URL.stitch(allocator, src, base);
|
||||
defer allocator.free(result);
|
||||
try testing.expectString("./abcdef/something.js", result);
|
||||
}
|
||||
|
||||
Submodule tests/wpt updated: b62cc86261...3a362d3f23
2
vendor/netsurf/libdom
vendored
2
vendor/netsurf/libdom
vendored
Submodule vendor/netsurf/libdom updated: b2c17b1476...b0f4a43147
Reference in New Issue
Block a user