mirror of
https://github.com/lightpanda-io/browser.git
synced 2025-10-29 15:13:28 +00:00
Compare commits
3 Commits
compilatio
...
event-targ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02cd5e98f9 | ||
|
|
a51c20068f | ||
|
|
99a3e4be3f |
20
.github/actions/install/action.yml
vendored
20
.github/actions/install/action.yml
vendored
@@ -5,7 +5,7 @@ inputs:
|
||||
zig:
|
||||
description: 'Zig version to install'
|
||||
required: false
|
||||
default: '0.14.1'
|
||||
default: '0.13.0'
|
||||
arch:
|
||||
description: 'CPU arch used to select the v8 lib'
|
||||
required: false
|
||||
@@ -17,11 +17,11 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.1.27'
|
||||
default: 'v0.1.11'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
default: '13.6.233.8'
|
||||
default: '11.1.134'
|
||||
cache-dir:
|
||||
description: 'cache dir to use'
|
||||
required: false
|
||||
@@ -34,11 +34,9 @@ runs:
|
||||
- name: Install apt deps
|
||||
if: ${{ inputs.os == 'linux' }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang
|
||||
run: sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang
|
||||
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
version: ${{ inputs.zig }}
|
||||
|
||||
@@ -61,11 +59,11 @@ runs:
|
||||
- name: install v8
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a
|
||||
mkdir -p vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/debug
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/debug/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
|
||||
mkdir -p vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/release
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a vendor/zig-js-runtime/vendor/v8/${{inputs.arch}}-${{inputs.os}}/release/libc_v8.a
|
||||
|
||||
- name: libiconv
|
||||
shell: bash
|
||||
|
||||
110
.github/workflows/build.yml
vendored
110
.github/workflows/build.yml
vendored
@@ -1,11 +1,5 @@
|
||||
name: nightly build
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.NIGHTLY_BUILD_AWS_ACCESS_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.NIGHTLY_BUILD_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "2 2 * * *"
|
||||
@@ -23,7 +17,6 @@ jobs:
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -33,59 +26,13 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
with:
|
||||
os: ${{env.OS}}
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: zig build
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dengine=v8 -Dcpu=x86_64
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: upload on s3
|
||||
run: |
|
||||
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
|
||||
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: Upload the build
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: nightly
|
||||
|
||||
build-linux-aarch64:
|
||||
env:
|
||||
ARCH: aarch64
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-22.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
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
|
||||
with:
|
||||
os: ${{env.OS}}
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: zig build
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: upload on s3
|
||||
run: |
|
||||
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
|
||||
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: Upload the build
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
@@ -98,10 +45,7 @@ jobs:
|
||||
ARCH: aarch64
|
||||
OS: macos
|
||||
|
||||
# macos-14 runs on arm CPU. see
|
||||
# https://github.com/actions/runner-images?tab=readme-ov-file
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 15
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -116,59 +60,11 @@ jobs:
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: zig build
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dengine=v8
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: upload on s3
|
||||
run: |
|
||||
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
|
||||
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: Upload the build
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: nightly
|
||||
|
||||
build-macos-x86_64:
|
||||
env:
|
||||
ARCH: x86_64
|
||||
OS: macos
|
||||
|
||||
# macos-13 runs on x86 CPU. see
|
||||
# https://github.com/actions/runner-images?tab=readme-ov-file
|
||||
# If we want to build for macos-14 or superior, we need to switch to
|
||||
# macos-14-large.
|
||||
# No need for now, but maybe we will need it in the short term.
|
||||
runs-on: macos-13
|
||||
timeout-minutes: 15
|
||||
|
||||
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
|
||||
with:
|
||||
os: ${{env.OS}}
|
||||
arch: ${{env.ARCH}}
|
||||
|
||||
- name: zig build
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: upload on s3
|
||||
run: |
|
||||
export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'`
|
||||
aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
|
||||
- name: Upload the build
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
|
||||
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -14,8 +14,6 @@ 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'
|
||||
@@ -28,7 +26,7 @@ jobs:
|
||||
path-to-document: 'https://github.com/lightpanda-io/browser/blob/main/CLA.md'
|
||||
# branch should not be protected
|
||||
branch: 'main'
|
||||
allowlist: krichprollsch,francisbouvier,katie-lpd,sjorsdonkers,bornlex
|
||||
allowlist: krichprollsch,francisbouvier,katie-lpd
|
||||
|
||||
remote-organization-name: lightpanda-io
|
||||
remote-repository-name: cla
|
||||
|
||||
211
.github/workflows/e2e-test.yml
vendored
211
.github/workflows/e2e-test.yml
vendored
@@ -1,211 +0,0 @@
|
||||
name: e2e-test
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ vars.LPD_PERF_AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.LPD_PERF_AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_BUCKET: ${{ vars.LPD_PERF_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/zig-js-runtime"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
pull_request:
|
||||
|
||||
# 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"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
zig-build-release:
|
||||
name: zig build release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
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
|
||||
|
||||
- name: zig build release
|
||||
run: zig build -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
demo-scripts:
|
||||
name: demo-scripts
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: run end to end tests
|
||||
run: |
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
go run runner/main.go --verbose
|
||||
kill `cat LPD.pid`
|
||||
|
||||
cdp-and-hyperfine-bench:
|
||||
name: cdp-and-hyperfine-bench
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
MAX_MEMORY: 27000
|
||||
MAX_AVG_DURATION: 23
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-bench-hetzner
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: start http
|
||||
run: |
|
||||
go run ws/main.go & echo $! > WS.pid
|
||||
sleep 2
|
||||
|
||||
- name: run puppeteer
|
||||
run: |
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
sleep 2
|
||||
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`
|
||||
|
||||
- name: puppeteer result
|
||||
run: cat puppeteer.out
|
||||
|
||||
- name: memory regression
|
||||
run: |
|
||||
export LPD_VmHWM=`cat LPD.VmHWM`
|
||||
echo "Peak resident set size: $LPD_VmHWM"
|
||||
test "$LPD_VmHWM" -le "$MAX_MEMORY"
|
||||
|
||||
- name: duration regression
|
||||
run: |
|
||||
export PUPPETEER_AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
|
||||
echo "puppeteer avg duration: $PUPPETEER_AVG_DURATION"
|
||||
test "$PUPPETEER_AVG_DURATION" -le "$MAX_AVG_DURATION"
|
||||
|
||||
- name: json output
|
||||
run: |
|
||||
export AVG_DURATION=`cat puppeteer.out|grep 'avg run'|sed 's/avg run duration (ms) //'`
|
||||
export TOTAL_DURATION=`cat puppeteer.out|grep 'total duration'|sed 's/total duration (ms) //'`
|
||||
export LPD_VmHWM=`cat LPD.VmHWM`
|
||||
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM}}" > bench.json
|
||||
cat bench.json
|
||||
|
||||
- name: run hyperfine
|
||||
run: |
|
||||
hyperfine --export-json=hyperfine.json --warmup 3 --runs 20 --shell=none "./lightpanda --dump http://127.0.0.1:1234/campfire-commerce/"
|
||||
|
||||
- name: stop http
|
||||
run: kill `cat WS.pid`
|
||||
|
||||
- name: write commit
|
||||
run: |
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
path: |
|
||||
bench.json
|
||||
hyperfine.json
|
||||
commit.txt
|
||||
retention-days: 10
|
||||
|
||||
|
||||
perf-fmt:
|
||||
name: perf-fmt
|
||||
needs: cdp-and-hyperfine-bench
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
|
||||
- name: format and send json result
|
||||
run: /perf-fmt cdp ${{ github.sha }} bench.json
|
||||
|
||||
- name: format and send json result
|
||||
run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json
|
||||
48
.github/workflows/wpt.yml
vendored
48
.github/workflows/wpt.yml
vendored
@@ -7,18 +7,44 @@ env:
|
||||
AWS_REGION: ${{ vars.LPD_PERF_AWS_REGION }}
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 2 * * *"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "build.zig"
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "tests/wpt/**"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
|
||||
# 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 json output
|
||||
name: web platform tests
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -29,8 +55,15 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- run: zig build wpt -Dengine=v8 -- --safe --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
|
||||
|
||||
- name: json output
|
||||
run: zig build wpt -- --json > wpt.json
|
||||
run: zig build wpt -Dengine=v8 -- --safe --json > wpt.json
|
||||
|
||||
- name: write commit
|
||||
run: |
|
||||
@@ -49,9 +82,10 @@ jobs:
|
||||
name: perf-fmt
|
||||
needs: wpt
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
|
||||
5
.github/workflows/zig-fmt.yml
vendored
5
.github/workflows/zig-fmt.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: zig-fmt
|
||||
|
||||
env:
|
||||
ZIG_VERSION: 0.14.1
|
||||
ZIG_VERSION: 0.13.0
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -29,10 +29,9 @@ jobs:
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: mlugg/setup-zig@v2
|
||||
- uses: mlugg/setup-zig@v1
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
|
||||
57
.github/workflows/zig-test.yml
vendored
57
.github/workflows/zig-test.yml
vendored
@@ -16,7 +16,6 @@ on:
|
||||
- "src/*.zig"
|
||||
- "vendor/zig-js-runtime"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
pull_request:
|
||||
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
@@ -33,7 +32,6 @@ on:
|
||||
- "src/*.zig"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -56,7 +54,7 @@ jobs:
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- name: zig build debug
|
||||
run: zig build
|
||||
run: zig build -Dengine=v8
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -66,28 +64,28 @@ jobs:
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
browser-fetch:
|
||||
name: browser fetch
|
||||
needs: zig-build-dev
|
||||
zig-build-release:
|
||||
name: zig build release
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
# Don't run the CI on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
name: lightpanda-build-dev
|
||||
fetch-depth: 0
|
||||
# fetch submodules recusively, to get zig-js-runtime submodules also.
|
||||
submodules: recursive
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- run: ./lightpanda fetch https://httpbin.io/xhr/get
|
||||
- name: zig build release
|
||||
run: zig build -Doptimize=ReleaseSafe -Dengine=v8
|
||||
|
||||
zig-test:
|
||||
name: zig test
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
@@ -104,7 +102,7 @@ jobs:
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- name: zig build test
|
||||
run: zig build test -- --json > bench.json
|
||||
run: zig build test -Dengine=v8 -- --json > bench.json
|
||||
|
||||
- name: write commit
|
||||
run: |
|
||||
@@ -127,8 +125,6 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
@@ -143,3 +139,30 @@ jobs:
|
||||
|
||||
- name: format and send json result
|
||||
run: /perf-fmt bench-browser ${{ github.sha }} bench.json
|
||||
|
||||
demo-puppeteer:
|
||||
name: demo-puppeteer
|
||||
needs: zig-build-dev
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-dev
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: run puppeteer
|
||||
run: |
|
||||
python3 -m http.server 1234 -d ./public &
|
||||
./lightpanda &
|
||||
RUNS=2 npm run bench-puppeteer-cdp
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,5 +3,3 @@ zig-cache
|
||||
zig-out
|
||||
/vendor/netsurf/out
|
||||
/vendor/libiconv/
|
||||
lightpanda.id
|
||||
/v8/
|
||||
|
||||
23
.gitmodules
vendored
23
.gitmodules
vendored
@@ -1,21 +1,34 @@
|
||||
[submodule "vendor/zig-js-runtime"]
|
||||
path = vendor/zig-js-runtime
|
||||
url = git@github.com:lightpanda-io/zig-js-runtime.git
|
||||
[submodule "vendor/netsurf/libwapcaplet"]
|
||||
path = vendor/netsurf/libwapcaplet
|
||||
url = https://github.com/lightpanda-io/libwapcaplet.git/
|
||||
url = git@github.com:lightpanda-io/libwapcaplet.git
|
||||
[submodule "vendor/netsurf/libparserutils"]
|
||||
path = vendor/netsurf/libparserutils
|
||||
url = https://github.com/lightpanda-io/libparserutils.git/
|
||||
url = git@github.com:lightpanda-io/libparserutils.git
|
||||
[submodule "vendor/netsurf/libdom"]
|
||||
path = vendor/netsurf/libdom
|
||||
url = https://github.com/lightpanda-io/libdom.git/
|
||||
url = git@github.com:lightpanda-io/libdom.git
|
||||
[submodule "vendor/netsurf/share/netsurf-buildsystem"]
|
||||
path = vendor/netsurf/share/netsurf-buildsystem
|
||||
url = https://source.netsurf-browser.org/buildsystem.git
|
||||
[submodule "vendor/netsurf/libhubbub"]
|
||||
path = vendor/netsurf/libhubbub
|
||||
url = https://github.com/lightpanda-io/libhubbub.git/
|
||||
url = git@github.com:lightpanda-io/libhubbub.git
|
||||
[submodule "tests/wpt"]
|
||||
path = tests/wpt
|
||||
url = https://github.com/lightpanda-io/wpt
|
||||
[submodule "vendor/mimalloc"]
|
||||
path = vendor/mimalloc
|
||||
url = https://github.com/microsoft/mimalloc.git/
|
||||
url = git@github.com:microsoft/mimalloc.git
|
||||
[submodule "vendor/tls.zig"]
|
||||
path = vendor/tls.zig
|
||||
url = git@github.com:ianic/tls.zig.git
|
||||
[submodule "vendor/zig-async-io"]
|
||||
path = vendor/zig-async-io
|
||||
url = git@github.com:lightpanda-io/zig-async-io.git
|
||||
[submodule "vendor/websocket.zig"]
|
||||
path = vendor/websocket.zig
|
||||
url = git@github.com:lightpanda-io/websocket.zig.git
|
||||
branch = lightpanda
|
||||
|
||||
67
Dockerfile
67
Dockerfile
@@ -1,11 +1,11 @@
|
||||
FROM debian:stable
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG=0.14.1
|
||||
ARG ZIG=0.13.0
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=13.6.233.8
|
||||
ARG ZIG_V8=v0.1.27
|
||||
ARG TARGETPLATFORM
|
||||
ARG OS=linux
|
||||
ARG ARCH=x86_64
|
||||
ARG V8=11.1.134
|
||||
ARG ZIG_V8=v0.1.9
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
apt-get install -yq xz-utils \
|
||||
@@ -16,23 +16,34 @@ RUN apt-get update -yq && \
|
||||
curl git
|
||||
|
||||
# install minisig
|
||||
RUN curl --fail -L -O https://github.com/jedisct1/minisign/releases/download/${MINISIG}/minisign-${MINISIG}-linux.tar.gz && \
|
||||
tar xvzf minisign-${MINISIG}-linux.tar.gz
|
||||
RUN curl -L -O https://github.com/jedisct1/minisign/releases/download/0.11/minisign-0.11-linux.tar.gz && \
|
||||
tar xvzf minisign-0.11-linux.tar.gz
|
||||
|
||||
# install zig
|
||||
RUN case $TARGETPLATFORM in \
|
||||
"linux/arm64") ARCH="aarch64" ;; \
|
||||
*) ARCH="x86_64" ;; \
|
||||
esac && \
|
||||
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz && \
|
||||
curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig && \
|
||||
minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${ZIG}.tar.xz -P ${ZIG_MINISIG} && \
|
||||
tar xvf zig-${ARCH}-linux-${ZIG}.tar.xz && \
|
||||
mv zig-${ARCH}-linux-${ZIG} /usr/local/lib && \
|
||||
ln -s /usr/local/lib/zig-${ARCH}-linux-${ZIG}/zig /usr/local/bin/zig
|
||||
RUN curl -O https://ziglang.org/download/${ZIG}/zig-linux-x86_64-${ZIG}.tar.xz && \
|
||||
curl -O https://ziglang.org/download/${ZIG}/zig-linux-x86_64-${ZIG}.tar.xz.minisig
|
||||
|
||||
RUN minisign-linux/x86_64/minisign -Vm zig-linux-x86_64-${ZIG}.tar.xz -P ${ZIG_MINISIG}
|
||||
|
||||
# clean minisg
|
||||
RUN rm -fr minisign-0.11-linux.tar.gz minisign-linux
|
||||
|
||||
# install zig
|
||||
RUN tar xvf zig-linux-x86_64-${ZIG}.tar.xz && \
|
||||
mv zig-linux-x86_64-${ZIG} /usr/local/lib && \
|
||||
ln -s /usr/local/lib/zig-linux-x86_64-${ZIG}/zig /usr/local/bin/zig
|
||||
|
||||
# clean up zig install
|
||||
RUN rm -fr zig-linux-x86_64-${ZIG}.tar.xz zig-linux-x86_64-${ZIG}.tar.xz.minisig
|
||||
|
||||
# force use of http instead of ssh with github
|
||||
RUN cat <<EOF > /root/.gitconfig
|
||||
[url "https://github.com/"]
|
||||
insteadOf="git@github.com:"
|
||||
EOF
|
||||
|
||||
# clone lightpanda
|
||||
RUN git clone https://github.com/lightpanda-io/browser.git
|
||||
RUN git clone git@github.com:lightpanda-io/browser.git
|
||||
|
||||
WORKDIR /browser
|
||||
|
||||
@@ -40,23 +51,23 @@ WORKDIR /browser
|
||||
RUN git submodule init && \
|
||||
git submodule update --recursive
|
||||
|
||||
RUN cd vendor/zig-js-runtime && \
|
||||
git submodule init && \
|
||||
git submodule update --recursive
|
||||
|
||||
RUN make install-libiconv && \
|
||||
make install-netsurf && \
|
||||
make install-mimalloc
|
||||
|
||||
# download and install v8
|
||||
RUN case $TARGETPLATFORM in \
|
||||
"linux/arm64") ARCH="aarch64" ;; \
|
||||
*) ARCH="x86_64" ;; \
|
||||
esac && \
|
||||
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/out/linux/release/obj/zig/ && \
|
||||
mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a
|
||||
RUN curl -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_${OS}_${ARCH}.a && \
|
||||
mkdir -p vendor/zig-js-runtime/vendor/v8/${ARCH}-${OS}/release && \
|
||||
mv libc_v8.a vendor/zig-js-runtime/vendor/v8/${ARCH}-${OS}/release/libc_v8.a
|
||||
|
||||
# build release
|
||||
RUN make build
|
||||
|
||||
FROM debian:stable-slim
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# copy ca certificates
|
||||
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
@@ -65,4 +76,4 @@ COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda
|
||||
|
||||
EXPOSE 9222/tcp
|
||||
|
||||
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"]
|
||||
CMD ["/bin/lightpanda", "--host", "0.0.0.0", "--port", "9222"]
|
||||
|
||||
@@ -10,6 +10,7 @@ The default license for this project is [AGPL-3.0-only](LICENSE).
|
||||
The following files are licensed under MIT:
|
||||
|
||||
```
|
||||
src/http/Client.zig
|
||||
src/polyfill/fetch.js
|
||||
```
|
||||
|
||||
|
||||
64
Makefile
64
Makefile
@@ -3,17 +3,12 @@
|
||||
|
||||
ZIG := zig
|
||||
BC := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
||||
# option test filter make test F="server"
|
||||
F=
|
||||
|
||||
# OS and ARCH
|
||||
kernel = $(shell uname -ms)
|
||||
ifeq ($(kernel), Darwin arm64)
|
||||
OS := macos
|
||||
ARCH := aarch64
|
||||
else ifeq ($(kernel), Darwin x86_64)
|
||||
OS := macos
|
||||
ARCH := x86_64
|
||||
else ifeq ($(kernel), Linux aarch64)
|
||||
OS := linux
|
||||
ARCH := aarch64
|
||||
@@ -47,8 +42,7 @@ help:
|
||||
|
||||
# $(ZIG) commands
|
||||
# ------------
|
||||
.PHONY: build build-dev run run-release shell test bench download-zig wpt data get-v8 build-v8 build-v8-dev
|
||||
.PHONY: end2end
|
||||
.PHONY: build build-dev run run-release shell test bench download-zig wpt
|
||||
|
||||
zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2)
|
||||
|
||||
@@ -63,74 +57,53 @@ download-zig:
|
||||
## Build in release-safe mode
|
||||
build:
|
||||
@printf "\e[36mBuilding (release safe)...\e[0m\n"
|
||||
$(ZIG) build -Doptimize=ReleaseSafe -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@$(ZIG) build -Doptimize=ReleaseSafe -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@printf "\e[33mBuild OK\e[0m\n"
|
||||
|
||||
## 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 -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@printf "\e[33mBuild OK\e[0m\n"
|
||||
|
||||
## 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
|
||||
run: build
|
||||
@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"
|
||||
@$(ZIG) build shell || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@$(ZIG) build shell -Dengine=v8 || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Run WPT tests
|
||||
wpt:
|
||||
@printf "\e[36mBuilding wpt...\e[0m\n"
|
||||
@$(ZIG) build wpt -- $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@$(ZIG) build wpt -Dengine=v8 -- --safe $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
wpt-summary:
|
||||
@printf "\e[36mBuilding wpt...\e[0m\n"
|
||||
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@$(ZIG) build wpt -Dengine=v8 -- --safe --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Test
|
||||
test:
|
||||
@TEST_FILTER='${F}' $(ZIG) build test -freference-trace --summary all
|
||||
|
||||
## Run demo/runner end to end tests
|
||||
end2end:
|
||||
@test -d ../demo
|
||||
cd ../demo && go run runner/main.go
|
||||
|
||||
## v8
|
||||
get-v8:
|
||||
@printf "\e[36mGetting v8 source...\e[0m\n"
|
||||
@$(ZIG) build get-v8
|
||||
|
||||
build-v8-dev:
|
||||
@printf "\e[36mBuilding v8 (dev)...\e[0m\n"
|
||||
@$(ZIG) build build-v8
|
||||
|
||||
build-v8:
|
||||
@printf "\e[36mBuilding v8...\e[0m\n"
|
||||
@$(ZIG) build -Doptimize=ReleaseSafe build-v8
|
||||
@printf "\e[36mTesting...\e[0m\n"
|
||||
@$(ZIG) build test -Dengine=v8 || (printf "\e[33mTest ERROR\e[0m\n"; exit 1;)
|
||||
@printf "\e[33mTest OK\e[0m\n"
|
||||
|
||||
# Install and build required dependencies commands
|
||||
# ------------
|
||||
.PHONY: install-submodule
|
||||
.PHONY: install-libiconv
|
||||
.PHONY: install-zig-js-runtime install-zig-js-runtime-dev install-libiconv
|
||||
.PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev
|
||||
.PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc
|
||||
.PHONY: install-dev install
|
||||
|
||||
## Install and build dependencies for release
|
||||
install: install-submodule install-libiconv install-netsurf install-mimalloc
|
||||
install: install-submodule install-zig-js-runtime install-libiconv install-netsurf install-mimalloc
|
||||
|
||||
## Install and build dependencies for dev
|
||||
install-dev: install-submodule install-libiconv install-netsurf-dev install-mimalloc-dev
|
||||
install-dev: install-submodule install-zig-js-runtime-dev install-libiconv install-netsurf-dev install-mimalloc-dev
|
||||
|
||||
install-netsurf-dev: _install-netsurf
|
||||
install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
|
||||
@@ -166,7 +139,7 @@ _install-netsurf: clean-netsurf
|
||||
BUILDDIR=$(BC_NS)/build/libdom make install && \
|
||||
printf "\e[33mRunning libdom example...\e[0m\n" && \
|
||||
cd examples && \
|
||||
$(ZIG) cc \
|
||||
zig cc \
|
||||
-I$(ICONV)/include \
|
||||
-I$(BC_NS)/include \
|
||||
-L$(ICONV)/lib \
|
||||
@@ -213,8 +186,13 @@ ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
|
||||
make clean
|
||||
endif
|
||||
|
||||
data:
|
||||
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
|
||||
install-zig-js-runtime-dev:
|
||||
@cd vendor/zig-js-runtime && \
|
||||
make install-dev
|
||||
|
||||
install-zig-js-runtime:
|
||||
@cd vendor/zig-js-runtime && \
|
||||
make install
|
||||
|
||||
.PHONY: _build_mimalloc
|
||||
|
||||
|
||||
244
README.md
244
README.md
@@ -7,81 +7,102 @@
|
||||
<p align="center"><a href="https://lightpanda.io/">lightpanda.io</a></p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/lightpanda-io/browser/blob/main/LICENSE)
|
||||
[](https://twitter.com/lightpanda_io)
|
||||
[](https://github.com/lightpanda-io/browser)
|
||||
|
||||
<br />
|
||||
</div>
|
||||
|
||||
Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
- Javascript execution
|
||||
- Support of Web APIs (partial, WIP)
|
||||
- Compatible with Playwright[^1], Puppeteer, chromedp through CDP
|
||||
- Compatible with Playwright, Puppeteer through CDP (WIP)
|
||||
|
||||
Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
Fast scraping and web automation with minimal memory footprint:
|
||||
|
||||
- Ultra-low memory footprint (9x less than Chrome)
|
||||
- Exceptionally fast execution (11x faster than Chrome)
|
||||
- Instant startup
|
||||
- Exceptionally fast execution (11x faster than Chrome) & instant startup
|
||||
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/execution-time.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
 
|
||||
[<img width="350px" src="https://cdn.lightpanda.io/assets/images/github/memory-frame.svg">
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
</div>
|
||||
<img width=500px src="https://cdn.lightpanda.io/assets/images/benchmark_2024-12-04.png">
|
||||
|
||||
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
|
||||
See [benchmark details](https://github.com/lightpanda-io/demo)._
|
||||
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.
|
||||
## Why?
|
||||
|
||||
### Javascript execution is mandatory for the modern web
|
||||
|
||||
In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. It’s not possible anymore, because Javascript is everywhere, like it or not:
|
||||
|
||||
- Ajax, Single Page App, infinite loading, “click to display”, instant search, etc.
|
||||
- JS web frameworks: React, Vue, Angular & others
|
||||
|
||||
### Chrome is not the right tool
|
||||
|
||||
If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure it’s such a good idea?
|
||||
|
||||
- Heavy on RAM and CPU, expensive to run
|
||||
- Hard to package, deploy and maintain at scale
|
||||
- Bloated, lots of features are not useful in headless usage
|
||||
|
||||
### Lightpanda is built for performance
|
||||
|
||||
If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But that’s we did:
|
||||
|
||||
- Not based on Chromium, Blink or WebKit
|
||||
- Low-level system programming language (Zig) with optimisations in mind
|
||||
- Opinionated: without graphical rendering
|
||||
|
||||
## Status
|
||||
|
||||
Lightpanda is still a work in progress and is currently at a Beta stage.
|
||||
|
||||
:warning: You should expect most websites to fail or crash.
|
||||
|
||||
Here are the key features we have implemented:
|
||||
|
||||
- [x] HTTP loader
|
||||
- [x] HTML parser and DOM tree (based on Netsurf libs)
|
||||
- [x] Javascript support (v8)
|
||||
- [x] Basic DOM APIs
|
||||
- [x] Ajax
|
||||
- [x] XHR API
|
||||
- [x] Fetch API
|
||||
- [x] DOM dump
|
||||
- [x] Basic CDP/websockets server
|
||||
|
||||
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||
|
||||
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Install
|
||||
**Install from the nightly builds**
|
||||
### Install from the nightly builds
|
||||
|
||||
You can download the last binary from the [nightly
|
||||
builds](https://github.com/lightpanda-io/browser/releases/tag/nightly) for
|
||||
Linux x86_64 and MacOS aarch64.
|
||||
|
||||
*For Linux*
|
||||
```console
|
||||
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \
|
||||
chmod a+x ./lightpanda
|
||||
# Download the binary
|
||||
$ wget https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux
|
||||
$ chmod a+x ./lightpanda-x86_64-linux
|
||||
$ ./lightpanda-x86_64-linux -h
|
||||
usage: ./lightpanda-x86_64-linux [options] [URL]
|
||||
|
||||
start Lightpanda browser
|
||||
|
||||
* if an url is provided the browser will fetch the page and exit
|
||||
* otherwhise the browser starts a CDP server
|
||||
|
||||
-h, --help Print this help message and exit.
|
||||
--host Host of the CDP server (default "127.0.0.1")
|
||||
--port Port of the CDP server (default "9222")
|
||||
--timeout Timeout for incoming connections of the CDP server (in seconds, default "3")
|
||||
--dump Dump document in stdout (fetch mode only)
|
||||
```
|
||||
|
||||
*For MacOS*
|
||||
```console
|
||||
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \
|
||||
chmod a+x ./lightpanda
|
||||
```
|
||||
|
||||
*For Windows + WSL2*
|
||||
|
||||
The Lightpanda browser is compatible to run on windows inside WSL. Follow the Linux instruction for installation from a WSL terminal.
|
||||
It is recommended to install clients like Puppeteer on the Windows host.
|
||||
|
||||
**Install from Docker**
|
||||
|
||||
Lightpanda provides [official Docker
|
||||
images](https://hub.docker.com/r/lightpanda/browser) for both Linux amd64 and
|
||||
arm64 architectures.
|
||||
The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port `9222`.
|
||||
The `--privileged` option is required because the browser requires `io_uring` syscalls which are blocked by default by Docker.
|
||||
```console
|
||||
docker run -d --name lightpanda -p 9222:9222 --privileged lightpanda/browser:nightly
|
||||
```
|
||||
|
||||
### Dump a URL
|
||||
### Dump an URL
|
||||
|
||||
```console
|
||||
./lightpanda fetch --dump https://lightpanda.io
|
||||
```
|
||||
```console
|
||||
$ ./lightpanda-x86_64-linux --dump https://lightpanda.io
|
||||
info(browser): GET https://lightpanda.io/ http.Status.ok
|
||||
info(browser): fetch script https://api.website.lightpanda.io/js/script.js: http.Status.ok
|
||||
info(browser): eval remote https://api.website.lightpanda.io/js/script.js: TypeError: Cannot read properties of undefined (reading 'pushState')
|
||||
@@ -91,9 +112,7 @@ info(browser): eval remote https://api.website.lightpanda.io/js/script.js: TypeE
|
||||
### Start a CDP server
|
||||
|
||||
```console
|
||||
./lightpanda serve --host 127.0.0.1 --port 9222
|
||||
```
|
||||
```console
|
||||
$ ./lightpanda-x86_64-linux --host 127.0.0.1 --port 9222
|
||||
info(websocket): starting blocking worker to listen on 127.0.0.1:9222
|
||||
info(server): accepting new conn...
|
||||
```
|
||||
@@ -102,7 +121,7 @@ Once the CDP server started, you can run a Puppeteer script by configuring the
|
||||
`browserWSEndpoint`.
|
||||
|
||||
```js
|
||||
'use strict'
|
||||
'use scrict'
|
||||
|
||||
import puppeteer from 'puppeteer-core';
|
||||
|
||||
@@ -115,58 +134,17 @@ const browser = await puppeteer.connect({
|
||||
const context = await browser.createBrowserContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Dump all the links from the page.
|
||||
await page.goto('https://wikipedia.com/');
|
||||
|
||||
const links = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(row => {
|
||||
return row.getAttribute('href');
|
||||
});
|
||||
});
|
||||
|
||||
console.log(links);
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
await browser.disconnect();
|
||||
```
|
||||
|
||||
### Telemetry
|
||||
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable `LIGHTPANDA_DISABLE_TELEMETRY=true`. You can read Lightpanda's privacy policy at: [https://lightpanda.io/privacy-policy](https://lightpanda.io/privacy-policy).
|
||||
|
||||
## Status
|
||||
|
||||
Lightpanda is in Beta and currently a work in progress. Stability and coverage are improving and many websites now work.
|
||||
You may still encounter errors or crashes. Please open an issue with specifics if so.
|
||||
|
||||
Here are the key features we have implemented:
|
||||
|
||||
- [x] HTTP loader
|
||||
- [x] HTTP loader
|
||||
- [x] HTML parser and DOM tree (based on Netsurf libs)
|
||||
- [x] Javascript support (v8)
|
||||
- [x] DOM APIs
|
||||
- [x] Ajax
|
||||
- [x] XHR API
|
||||
- [x] Fetch API (polyfill)
|
||||
- [x] DOM dump
|
||||
- [x] CDP/websockets server
|
||||
- [x] Click
|
||||
- [x] Input form
|
||||
- [x] Cookies
|
||||
- [x] Custom HTTP headers
|
||||
- [ ] Proxy support
|
||||
- [ ] Network interception
|
||||
|
||||
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||
|
||||
You can also follow the progress of our Javascript support in our dedicated [zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime#development) project.
|
||||
|
||||
## Build from sources
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Lightpanda is written with [Zig](https://ziglang.org/) `0.14.1`. You have to
|
||||
Lightpanda is written with [Zig](https://ziglang.org/) `0.13.0`. You have to
|
||||
install it with the right version in order to build the project.
|
||||
|
||||
Lightpanda also depends on
|
||||
@@ -182,15 +160,10 @@ For Debian/Ubuntu based Linux:
|
||||
sudo apt install xz-utils \
|
||||
python3 ca-certificates git \
|
||||
pkg-config libglib2.0-dev \
|
||||
gperf libexpat1-dev unzip rsync \
|
||||
gperf libexpat1-dev \
|
||||
cmake clang
|
||||
```
|
||||
|
||||
For systems with [Nix](https://nixos.org/download/), you can use the devShell:
|
||||
```
|
||||
nix develop
|
||||
```
|
||||
|
||||
For MacOS, you only need cmake:
|
||||
|
||||
```
|
||||
@@ -203,9 +176,9 @@ brew install cmake
|
||||
|
||||
You can run `make install` to install deps all in one (or `make install-dev` if you need the development versions).
|
||||
|
||||
Be aware that the build task is very long and cpu consuming, as you will build from sources all dependencies, including the v8 Javascript engine.
|
||||
Be aware that the build task is very long and cpu consuming, as you will build from sources all dependancies, including the v8 Javascript engine.
|
||||
|
||||
#### Step by step build dependency
|
||||
#### Step by step build dependancy
|
||||
|
||||
The project uses git submodules for dependencies.
|
||||
|
||||
@@ -215,14 +188,6 @@ To init or update the submodules in the `vendor/` directory:
|
||||
make install-submodule
|
||||
```
|
||||
|
||||
**iconv**
|
||||
|
||||
libiconv is an internationalization library used by Netsurf.
|
||||
|
||||
```
|
||||
make install-libiconv
|
||||
```
|
||||
|
||||
**Netsurf libs**
|
||||
|
||||
Netsurf libs are used for HTML parsing and DOM tree generation.
|
||||
@@ -247,21 +212,17 @@ Note: when Mimalloc is built in dev mode, you can dump memory stats with the
|
||||
env var `MIMALLOC_SHOW_STATS=1`. See
|
||||
[https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html).
|
||||
|
||||
**v8**
|
||||
**zig-js-runtime**
|
||||
|
||||
First, get the tools necessary for building V8, as well as the V8 source code:
|
||||
Our own Zig/Javascript runtime, which includes the v8 Javascript engine.
|
||||
|
||||
This build task is very long and cpu consuming, as you will build v8 from sources.
|
||||
|
||||
```
|
||||
make get-v8
|
||||
make install-zig-js-runtime
|
||||
```
|
||||
|
||||
Next, build v8. This build task is very long and cpu consuming, as you will build v8 from sources.
|
||||
|
||||
```
|
||||
make build-v8
|
||||
```
|
||||
|
||||
For dev env, use `make build-v8-dev`.
|
||||
For dev env, use `make iinstall-zig-js-runtime-dev`.
|
||||
|
||||
## Test
|
||||
|
||||
@@ -269,20 +230,6 @@ For dev env, use `make build-v8-dev`.
|
||||
|
||||
You can test Lightpanda by running `make test`.
|
||||
|
||||
### End to end tests
|
||||
|
||||
To run end to end tests, you need to clone the [demo
|
||||
repository](https://github.com/lightpanda-io/demo) into `../demo` dir.
|
||||
|
||||
You have to install the [demo's node
|
||||
requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1)
|
||||
|
||||
You also need to install [Go](https://go.dev) > v1.24.
|
||||
|
||||
```
|
||||
make end2end
|
||||
```
|
||||
|
||||
### Web Platform Tests
|
||||
|
||||
Lightpanda is tested against the standardized [Web Platform
|
||||
@@ -324,28 +271,3 @@ Lightpanda accepts pull requests through GitHub.
|
||||
|
||||
You have to sign our [CLA](CLA.md) during the pull request process otherwise
|
||||
we're not able to accept your contributions.
|
||||
|
||||
## Why?
|
||||
|
||||
### Javascript execution is mandatory for the modern web
|
||||
|
||||
In the good old days, scraping a webpage was as easy as making an HTTP request, cURL-like. It’s not possible anymore, because Javascript is everywhere, like it or not:
|
||||
|
||||
- Ajax, Single Page App, infinite loading, “click to display”, instant search, etc.
|
||||
- JS web frameworks: React, Vue, Angular & others
|
||||
|
||||
### Chrome is not the right tool
|
||||
|
||||
If we need Javascript, why not use a real web browser? Take a huge desktop application, hack it, and run it on the server. Hundreds or thousands of instances of Chrome if you use it at scale. Are you sure it’s such a good idea?
|
||||
|
||||
- Heavy on RAM and CPU, expensive to run
|
||||
- Hard to package, deploy and maintain at scale
|
||||
- Bloated, lots of features are not useful in headless usage
|
||||
|
||||
### Lightpanda is built for performance
|
||||
|
||||
If we want both Javascript and performance in a true headless browser, we need to start from scratch. Not another iteration of Chromium, really from a blank page. Crazy right? But that’s what we did:
|
||||
|
||||
- Not based on Chromium, Blink or WebKit
|
||||
- Low-level system programming language (Zig) with optimisations in mind
|
||||
- Opinionated: without graphical rendering
|
||||
|
||||
313
build.zig
313
build.zig
@@ -17,11 +17,16 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const jsruntime_path = "vendor/zig-js-runtime/";
|
||||
const jsruntime = @import("vendor/zig-js-runtime/build.zig");
|
||||
const jsruntime_pkgs = jsruntime.packages(jsruntime_path);
|
||||
|
||||
/// Do not rename this constant. It is scanned by some scripts to determine
|
||||
/// which zig version to install.
|
||||
const recommended_zig_version = "0.14.1";
|
||||
const recommended_zig_version = jsruntime.recommended_zig_version;
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {
|
||||
@@ -37,202 +42,173 @@ pub fn build(b: *std.Build) !void {
|
||||
},
|
||||
}
|
||||
|
||||
var opts = b.addOptions();
|
||||
opts.addOption(
|
||||
[]const u8,
|
||||
"git_commit",
|
||||
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
|
||||
);
|
||||
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
const mode = b.standardOptimizeOption(.{});
|
||||
|
||||
{
|
||||
// browser
|
||||
// -------
|
||||
const options = jsruntime.buildOptions(b);
|
||||
|
||||
// compile and install
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "lightpanda",
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
});
|
||||
// browser
|
||||
// -------
|
||||
|
||||
try common(b, opts, exe);
|
||||
b.installArtifact(exe);
|
||||
// compile and install
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "lightpanda",
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = mode,
|
||||
});
|
||||
try common(b, exe, options);
|
||||
b.installArtifact(exe);
|
||||
|
||||
// run
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
// step
|
||||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
// run
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
{
|
||||
// get v8
|
||||
// -------
|
||||
const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize });
|
||||
const get_v8 = b.addRunArtifact(v8.artifact("get-v8"));
|
||||
const get_step = b.step("get-v8", "Get v8");
|
||||
get_step.dependOn(&get_v8.step);
|
||||
// step
|
||||
const run_step = b.step("run", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
|
||||
// shell
|
||||
// -----
|
||||
|
||||
// compile and install
|
||||
const shell = b.addExecutable(.{
|
||||
.name = "lightpanda-shell",
|
||||
.root_source_file = b.path("src/main_shell.zig"),
|
||||
.target = target,
|
||||
.optimize = mode,
|
||||
});
|
||||
try common(b, shell, options);
|
||||
try jsruntime_pkgs.add_shell(shell);
|
||||
|
||||
// run
|
||||
const shell_cmd = b.addRunArtifact(shell);
|
||||
if (b.args) |args| {
|
||||
shell_cmd.addArgs(args);
|
||||
}
|
||||
|
||||
{
|
||||
// build v8
|
||||
// -------
|
||||
const v8 = b.dependency("v8", .{ .target = target, .optimize = optimize });
|
||||
const build_v8 = b.addRunArtifact(v8.artifact("build-v8"));
|
||||
const build_step = b.step("build-v8", "Build v8");
|
||||
build_step.dependOn(&build_v8.step);
|
||||
// step
|
||||
const shell_step = b.step("shell", "Run JS shell");
|
||||
shell_step.dependOn(&shell_cmd.step);
|
||||
|
||||
// test
|
||||
// ----
|
||||
|
||||
// compile
|
||||
const tests = b.addTest(.{
|
||||
.root_source_file = b.path("src/run_tests.zig"),
|
||||
.test_runner = b.path("src/test_runner.zig"),
|
||||
.target = target,
|
||||
.optimize = mode,
|
||||
});
|
||||
try common(b, tests, options);
|
||||
|
||||
// add jsruntime pretty deps
|
||||
tests.root_module.addAnonymousImport("pretty", .{
|
||||
.root_source_file = b.path("vendor/zig-js-runtime/src/pretty.zig"),
|
||||
});
|
||||
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
if (b.args) |args| {
|
||||
run_tests.addArgs(args);
|
||||
}
|
||||
|
||||
{
|
||||
// tests
|
||||
// ----
|
||||
// step
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_tests.step);
|
||||
|
||||
// compile
|
||||
const tests = b.addTest(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.test_runner = .{ .path = b.path("src/test_runner.zig"), .mode = .simple },
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
try common(b, opts, tests);
|
||||
// wpt
|
||||
// -----
|
||||
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
if (b.args) |args| {
|
||||
run_tests.addArgs(args);
|
||||
}
|
||||
// compile and install
|
||||
const wpt = b.addExecutable(.{
|
||||
.name = "lightpanda-wpt",
|
||||
.root_source_file = b.path("src/main_wpt.zig"),
|
||||
.target = target,
|
||||
.optimize = mode,
|
||||
});
|
||||
try common(b, wpt, options);
|
||||
|
||||
// step
|
||||
const tests_step = b.step("test", "Run unit tests");
|
||||
tests_step.dependOn(&run_tests.step);
|
||||
}
|
||||
|
||||
{
|
||||
// wpt
|
||||
// -----
|
||||
|
||||
// compile and install
|
||||
const wpt = b.addExecutable(.{
|
||||
.name = "lightpanda-wpt",
|
||||
.root_source_file = b.path("src/main_wpt.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
});
|
||||
try common(b, opts, wpt);
|
||||
|
||||
// run
|
||||
const wpt_cmd = b.addRunArtifact(wpt);
|
||||
if (b.args) |args| {
|
||||
wpt_cmd.addArgs(args);
|
||||
}
|
||||
// step
|
||||
const wpt_step = b.step("wpt", "WPT tests");
|
||||
wpt_step.dependOn(&wpt_cmd.step);
|
||||
// run
|
||||
const wpt_cmd = b.addRunArtifact(wpt);
|
||||
if (b.args) |args| {
|
||||
wpt_cmd.addArgs(args);
|
||||
}
|
||||
// step
|
||||
const wpt_step = b.step("wpt", "WPT tests");
|
||||
wpt_step.dependOn(&wpt_cmd.step);
|
||||
}
|
||||
|
||||
fn common(b: *std.Build, opts: *std.Build.Step.Options, step: *std.Build.Step.Compile) !void {
|
||||
const mod = step.root_module;
|
||||
const target = mod.resolved_target.?;
|
||||
const optimize = mod.optimize.?;
|
||||
const dep_opts = .{ .target = target, .optimize = optimize };
|
||||
fn common(
|
||||
b: *std.Build,
|
||||
step: *std.Build.Step.Compile,
|
||||
options: jsruntime.Options,
|
||||
) !void {
|
||||
const target = step.root_module.resolved_target.?;
|
||||
const jsruntimemod = try jsruntime_pkgs.module(
|
||||
b,
|
||||
options,
|
||||
step.root_module.optimize.?,
|
||||
target,
|
||||
);
|
||||
step.root_module.addImport("jsruntime", jsruntimemod);
|
||||
|
||||
try moduleNetSurf(b, step, target);
|
||||
mod.addImport("tls", b.dependency("tls", dep_opts).module("tls"));
|
||||
mod.addImport("tigerbeetle-io", b.dependency("tigerbeetle_io", .{}).module("tigerbeetle_io"));
|
||||
const netsurf = try moduleNetSurf(b, target);
|
||||
netsurf.addImport("jsruntime", jsruntimemod);
|
||||
step.root_module.addImport("netsurf", netsurf);
|
||||
|
||||
{
|
||||
// v8
|
||||
const v8_opts = b.addOptions();
|
||||
v8_opts.addOption(bool, "inspector_subtype", false);
|
||||
const asyncio = b.addModule("asyncio", .{
|
||||
.root_source_file = b.path("vendor/zig-async-io/src/lib.zig"),
|
||||
});
|
||||
step.root_module.addImport("asyncio", asyncio);
|
||||
|
||||
const v8_mod = b.dependency("v8", dep_opts).module("v8");
|
||||
v8_mod.addOptions("default_exports", v8_opts);
|
||||
mod.addImport("v8", v8_mod);
|
||||
}
|
||||
const tlsmod = b.addModule("tls", .{
|
||||
.root_source_file = b.path("vendor/tls.zig/src/main.zig"),
|
||||
});
|
||||
step.root_module.addImport("tls", tlsmod);
|
||||
|
||||
mod.link_libcpp = true;
|
||||
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
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());
|
||||
const wsmod = b.addModule("websocket", .{
|
||||
.root_source_file = b.path("vendor/websocket.zig/src/websocket.zig"),
|
||||
});
|
||||
step.root_module.addImport("websocket", wsmod);
|
||||
}
|
||||
|
||||
fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
|
||||
fn moduleNetSurf(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Module {
|
||||
const mod = b.addModule("netsurf", .{
|
||||
.root_source_file = b.path("src/netsurf/netsurf.zig"),
|
||||
.target = target,
|
||||
});
|
||||
|
||||
const os = target.result.os.tag;
|
||||
const arch = target.result.cpu.arch;
|
||||
|
||||
// iconv
|
||||
const libiconv_lib_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
mod.owner.allocator,
|
||||
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
const libiconv_include_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
mod.owner.allocator,
|
||||
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addObjectFile(b.path(libiconv_lib_path));
|
||||
step.addIncludePath(b.path(libiconv_include_path));
|
||||
mod.addObjectFile(b.path(libiconv_lib_path));
|
||||
mod.addIncludePath(b.path(libiconv_include_path));
|
||||
|
||||
{
|
||||
// mimalloc
|
||||
const mimalloc = "vendor/mimalloc";
|
||||
const lib_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addObjectFile(b.path(lib_path));
|
||||
step.addIncludePath(b.path(mimalloc ++ "/include"));
|
||||
}
|
||||
// mimalloc
|
||||
mod.addImport("mimalloc", (try moduleMimalloc(b, target)));
|
||||
|
||||
// netsurf libs
|
||||
const ns = "vendor/netsurf";
|
||||
const ns_include_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
mod.owner.allocator,
|
||||
ns ++ "/out/{s}-{s}/include",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addIncludePath(b.path(ns_include_path));
|
||||
mod.addIncludePath(b.path(ns_include_path));
|
||||
|
||||
const libs: [4][]const u8 = .{
|
||||
"libdom",
|
||||
@@ -242,11 +218,34 @@ fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build
|
||||
};
|
||||
inline for (libs) |lib| {
|
||||
const ns_lib_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
mod.owner.allocator,
|
||||
ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addObjectFile(b.path(ns_lib_path));
|
||||
step.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
|
||||
mod.addObjectFile(b.path(ns_lib_path));
|
||||
mod.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
|
||||
}
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
fn moduleMimalloc(b: *std.Build, target: std.Build.ResolvedTarget) !*std.Build.Module {
|
||||
const mod = b.addModule("mimalloc", .{
|
||||
.root_source_file = b.path("src/mimalloc/mimalloc.zig"),
|
||||
.target = target,
|
||||
});
|
||||
|
||||
const os = target.result.os.tag;
|
||||
const arch = target.result.cpu.arch;
|
||||
|
||||
const mimalloc = "vendor/mimalloc";
|
||||
const lib_path = try std.fmt.allocPrint(
|
||||
mod.owner.allocator,
|
||||
mimalloc ++ "/out/{s}-{s}/lib/libmimalloc.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
mod.addObjectFile(b.path(lib_path));
|
||||
mod.addIncludePath(b.path(mimalloc ++ "/include"));
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
.{
|
||||
.name = .browser,
|
||||
.paths = .{""},
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xda130f3af836cea0,
|
||||
.dependencies = .{
|
||||
.tls = .{
|
||||
.url = "https://github.com/ianic/tls.zig/archive/55845f755d9e2e821458ea55693f85c737cd0c7a.tar.gz",
|
||||
.hash = "tls-0.1.0-ER2e0m43BQAshi8ixj1qf3w2u2lqKtXtkrxUJ4AGZDcl",
|
||||
},
|
||||
.tigerbeetle_io = .{
|
||||
.url = "https://github.com/lightpanda-io/tigerbeetle-io/archive/61d9652f1a957b7f4db723ea6aa0ce9635e840ce.tar.gz",
|
||||
.hash = "tigerbeetle_io-0.0.0-ViLgxpyRBAB5BMfIcj3KMXfbJzwARs9uSl8aRy2OXULd",
|
||||
},
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/dd087771378ea854452bcb010309fa9ffe5a9cac.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH66e8AwBL3O_A8yWQYQIyfMbKHFNVQr_NqM6YjU11",
|
||||
},
|
||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
||||
},
|
||||
}
|
||||
61
flake.lock
generated
61
flake.lock
generated
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1748964450,
|
||||
"narHash": "sha256-ZouDiXkUk8mkMnah10QcoQ9Nu6UW6AFAHLScS3En6aI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9ff500cd9e123f46c55855eca64beccead29b152",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "release-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
59
flake.nix
59
flake.nix
@@ -1,59 +0,0 @@
|
||||
{
|
||||
description = "headless browser designed for AI and automation";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/release-25.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
|
||||
# We need crtbeginS.o for building.
|
||||
crtFiles = pkgs.runCommand "crt-files" { } ''
|
||||
mkdir -p $out/lib
|
||||
cp -r ${pkgs.gcc.cc}/lib/gcc $out/lib/gcc
|
||||
'';
|
||||
|
||||
# This build pipeline is very unhappy without an FHS-compliant env.
|
||||
fhs = pkgs.buildFHSEnv {
|
||||
name = "fhs-shell";
|
||||
multiArch = true;
|
||||
targetPkgs =
|
||||
pkgs: with pkgs; [
|
||||
# Build Tools
|
||||
zig
|
||||
zls
|
||||
python3
|
||||
pkg-config
|
||||
cmake
|
||||
gperf
|
||||
|
||||
# GCC
|
||||
gcc
|
||||
gcc.cc.lib
|
||||
crtFiles
|
||||
|
||||
# Libaries
|
||||
expat.dev
|
||||
glib.dev
|
||||
glibc.dev
|
||||
zlib
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = fhs.env;
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -16,27 +16,30 @@
|
||||
// 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 generate = @import("generate.zig");
|
||||
|
||||
const CSSStyleSheet = @import("css_stylesheet.zig").CSSStyleSheet;
|
||||
const Console = @import("jsruntime").Console;
|
||||
|
||||
pub const Interfaces = .{
|
||||
CSSRule,
|
||||
CSSImportRule,
|
||||
};
|
||||
const DOM = @import("dom/dom.zig");
|
||||
const HTML = @import("html/html.zig");
|
||||
const Events = @import("events/event.zig");
|
||||
const XHR = @import("xhr/xhr.zig");
|
||||
const Storage = @import("storage/storage.zig");
|
||||
const URL = @import("url/url.zig");
|
||||
const Iterators = @import("iterator/iterator.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule
|
||||
pub const CSSRule = struct {
|
||||
css_text: []const u8,
|
||||
parent_rule: ?*CSSRule = null,
|
||||
parent_stylesheet: ?*CSSStyleSheet = null,
|
||||
};
|
||||
pub const HTMLDocument = @import("html/document.zig").HTMLDocument;
|
||||
|
||||
pub const CSSImportRule = struct {
|
||||
pub const prototype = *CSSRule;
|
||||
href: []const u8,
|
||||
layer_name: ?[]const u8,
|
||||
media: void,
|
||||
style_sheet: CSSStyleSheet,
|
||||
supports_text: ?[]const u8,
|
||||
};
|
||||
// Interfaces
|
||||
pub const Interfaces = generate.Tuple(.{
|
||||
Console,
|
||||
DOM.Interfaces,
|
||||
Events.Interfaces,
|
||||
HTML.Interfaces,
|
||||
XHR.Interfaces,
|
||||
Storage.Interfaces,
|
||||
URL.Interfaces,
|
||||
Iterators.Interfaces,
|
||||
});
|
||||
|
||||
pub const UserContext = @import("user_context.zig").UserContext;
|
||||
109
src/app.zig
109
src/app.zig
@@ -1,109 +0,0 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Loop = @import("runtime/loop.zig").Loop;
|
||||
const http = @import("http/client.zig");
|
||||
const Platform = @import("runtime/js.zig").Platform;
|
||||
|
||||
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
||||
const Notification = @import("notification.zig").Notification;
|
||||
|
||||
// Container for global state / objects that various parts of the system
|
||||
// might need.
|
||||
pub const App = struct {
|
||||
loop: *Loop,
|
||||
config: Config,
|
||||
platform: ?*const Platform,
|
||||
allocator: Allocator,
|
||||
telemetry: Telemetry,
|
||||
http_client: http.Client,
|
||||
app_dir_path: ?[]const u8,
|
||||
notification: *Notification,
|
||||
|
||||
pub const RunMode = enum {
|
||||
help,
|
||||
fetch,
|
||||
serve,
|
||||
version,
|
||||
};
|
||||
|
||||
pub const Config = struct {
|
||||
run_mode: RunMode,
|
||||
platform: ?*const Platform = null,
|
||||
tls_verify_host: bool = true,
|
||||
http_proxy: ?std.Uri = null,
|
||||
proxy_type: ?http.ProxyType = null,
|
||||
proxy_auth: ?http.ProxyAuth = null,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator, config: Config) !*App {
|
||||
const app = try allocator.create(App);
|
||||
errdefer allocator.destroy(app);
|
||||
|
||||
const loop = try allocator.create(Loop);
|
||||
errdefer allocator.destroy(loop);
|
||||
|
||||
loop.* = try Loop.init(allocator);
|
||||
errdefer loop.deinit();
|
||||
|
||||
const notification = try Notification.init(allocator, null);
|
||||
errdefer notification.deinit();
|
||||
|
||||
const app_dir_path = getAndMakeAppDir(allocator);
|
||||
|
||||
app.* = .{
|
||||
.loop = loop,
|
||||
.allocator = allocator,
|
||||
.telemetry = undefined,
|
||||
.platform = config.platform,
|
||||
.app_dir_path = app_dir_path,
|
||||
.notification = notification,
|
||||
.http_client = try http.Client.init(allocator, loop, .{
|
||||
.max_concurrent = 3,
|
||||
.http_proxy = config.http_proxy,
|
||||
.proxy_type = config.proxy_type,
|
||||
.proxy_auth = config.proxy_auth,
|
||||
.tls_verify_host = config.tls_verify_host,
|
||||
}),
|
||||
.config = config,
|
||||
};
|
||||
app.telemetry = Telemetry.init(app, config.run_mode);
|
||||
try app.telemetry.register(app.notification);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
const allocator = self.allocator;
|
||||
if (self.app_dir_path) |app_dir_path| {
|
||||
allocator.free(app_dir_path);
|
||||
}
|
||||
self.telemetry.deinit();
|
||||
self.loop.deinit();
|
||||
allocator.destroy(self.loop);
|
||||
self.http_client.deinit();
|
||||
self.notification.deinit();
|
||||
allocator.destroy(self);
|
||||
}
|
||||
};
|
||||
|
||||
fn getAndMakeAppDir(allocator: Allocator) ?[]const u8 {
|
||||
if (@import("builtin").is_test) {
|
||||
return allocator.dupe(u8, "/tmp") catch unreachable;
|
||||
}
|
||||
const app_dir_path = std.fs.getAppDataDir(allocator, "lightpanda") catch |err| {
|
||||
log.warn(.app, "get data dir", .{ .err = err });
|
||||
return null;
|
||||
};
|
||||
|
||||
std.fs.cwd().makePath(app_dir_path) catch |err| switch (err) {
|
||||
error.PathAlreadyExists => return app_dir_path,
|
||||
else => {
|
||||
allocator.free(app_dir_path);
|
||||
log.warn(.app, "create data dir", .{ .err = err, .path = app_dir_path });
|
||||
return null;
|
||||
},
|
||||
};
|
||||
return app_dir_path;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
// Sometimes we need to extend libdom. For example, its HTMLDocument doesn't
|
||||
// have a readyState. We have a couple different options, such as making the
|
||||
// correction in libdom directly. Another option stems from the fact that every
|
||||
// libdom node has an opaque embedder_data field. This is the struct that we
|
||||
// lazily load into that field.
|
||||
//
|
||||
// It didn't originally start off as a collection of every single extension, but
|
||||
// this quickly proved necessary, since different fields are needed on the same
|
||||
// data at different levels of the prototype chain. This isn't memory efficient.
|
||||
|
||||
const Env = @import("env.zig").Env;
|
||||
const parser = @import("netsurf.zig");
|
||||
const DataSet = @import("html/DataSet.zig");
|
||||
const ShadowRoot = @import("dom/shadow_root.zig").ShadowRoot;
|
||||
const CSSStyleDeclaration = @import("cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
|
||||
// for HTMLScript (but probably needs to be added to more)
|
||||
onload: ?Env.Function = null,
|
||||
onerror: ?Env.Function = null,
|
||||
|
||||
// for HTMLElement
|
||||
style: CSSStyleDeclaration = .empty,
|
||||
dataset: ?DataSet = null,
|
||||
|
||||
// for html/document
|
||||
ready_state: ReadyState = .loading,
|
||||
|
||||
// for dom/document
|
||||
active_element: ?*parser.Element = null,
|
||||
|
||||
// for HTMLSelectElement
|
||||
// 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,
|
||||
|
||||
template_content: ?*parser.DocumentFragment = null,
|
||||
|
||||
shadow_root: ?*ShadowRoot = null,
|
||||
|
||||
const ReadyState = enum {
|
||||
loading,
|
||||
interactive,
|
||||
complete,
|
||||
};
|
||||
@@ -17,104 +17,646 @@
|
||||
// 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 Types = @import("root").Types;
|
||||
|
||||
const State = @import("State.zig");
|
||||
const Env = @import("env.zig").Env;
|
||||
const App = @import("../app.zig").App;
|
||||
const Session = @import("session.zig").Session;
|
||||
const Notification = @import("../notification.zig").Notification;
|
||||
const parser = @import("netsurf");
|
||||
const Loader = @import("loader.zig").Loader;
|
||||
const Dump = @import("dump.zig");
|
||||
const Mime = @import("mime.zig");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const jsruntime = @import("jsruntime");
|
||||
const Loop = jsruntime.Loop;
|
||||
const Env = jsruntime.Env;
|
||||
const Module = jsruntime.Module;
|
||||
|
||||
const http = @import("../http/client.zig");
|
||||
const apiweb = @import("../apiweb.zig");
|
||||
|
||||
const Window = @import("../html/window.zig").Window;
|
||||
const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
|
||||
|
||||
const storage = @import("../storage/storage.zig");
|
||||
|
||||
const FetchResult = @import("../http/Client.zig").Client.FetchResult;
|
||||
|
||||
const UserContext = @import("../user_context.zig").UserContext;
|
||||
const HttpClient = @import("asyncio").Client;
|
||||
|
||||
const polyfill = @import("../polyfill/polyfill.zig");
|
||||
|
||||
const log = std.log.scoped(.browser);
|
||||
|
||||
pub const user_agent = "Lightpanda/1.0";
|
||||
|
||||
// Browser is an instance of the browser.
|
||||
// You can create multiple browser instances.
|
||||
// A browser contains only one session.
|
||||
// TODO allow multiple sessions per browser.
|
||||
pub const Browser = struct {
|
||||
env: *Env,
|
||||
app: *App,
|
||||
session: ?Session,
|
||||
allocator: Allocator,
|
||||
http_client: *http.Client,
|
||||
page_arena: ArenaAllocator,
|
||||
session_arena: ArenaAllocator,
|
||||
transfer_arena: ArenaAllocator,
|
||||
notification: *Notification,
|
||||
state_pool: std.heap.MemoryPool(State),
|
||||
session: Session = undefined,
|
||||
agent: []const u8 = user_agent,
|
||||
|
||||
pub fn init(app: *App) !Browser {
|
||||
const allocator = app.allocator;
|
||||
const uri = "about:blank";
|
||||
|
||||
const env = try Env.init(allocator, app.platform, .{});
|
||||
errdefer env.deinit();
|
||||
pub fn init(self: *Browser, alloc: std.mem.Allocator, loop: *Loop, vm: jsruntime.VM) !void {
|
||||
// We want to ensure the caller initialised a VM, but the browser
|
||||
// doesn't use it directly...
|
||||
_ = vm;
|
||||
|
||||
const notification = try Notification.init(allocator, app.notification);
|
||||
errdefer notification.deinit();
|
||||
|
||||
return .{
|
||||
.app = app,
|
||||
.env = env,
|
||||
.session = null,
|
||||
.allocator = allocator,
|
||||
.notification = notification,
|
||||
.http_client = &app.http_client,
|
||||
.page_arena = ArenaAllocator.init(allocator),
|
||||
.session_arena = ArenaAllocator.init(allocator),
|
||||
.transfer_arena = ArenaAllocator.init(allocator),
|
||||
.state_pool = std.heap.MemoryPool(State).init(allocator),
|
||||
};
|
||||
try Session.init(&self.session, alloc, loop, uri);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Browser) void {
|
||||
self.closeSession();
|
||||
self.env.deinit();
|
||||
self.page_arena.deinit();
|
||||
self.session_arena.deinit();
|
||||
self.transfer_arena.deinit();
|
||||
self.notification.deinit();
|
||||
self.state_pool.deinit();
|
||||
self.session.deinit();
|
||||
}
|
||||
|
||||
pub fn newSession(self: *Browser) !*Session {
|
||||
self.closeSession();
|
||||
self.session = @as(Session, undefined);
|
||||
const session = &self.session.?;
|
||||
try Session.init(session, self);
|
||||
return session;
|
||||
}
|
||||
|
||||
pub fn closeSession(self: *Browser) void {
|
||||
if (self.session) |*session| {
|
||||
session.deinit();
|
||||
self.session = null;
|
||||
_ = self.session_arena.reset(.{ .retain_with_limit = 1 * 1024 * 1024 });
|
||||
self.env.lowMemoryNotification();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runMicrotasks(self: *const Browser) void {
|
||||
self.env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn runMessageLoop(self: *const Browser) void {
|
||||
while (self.env.pumpMessageLoop()) {
|
||||
log.debug(.browser, "pumpMessageLoop", .{});
|
||||
}
|
||||
self.env.runIdleTasks();
|
||||
pub fn newSession(
|
||||
self: *Browser,
|
||||
alloc: std.mem.Allocator,
|
||||
loop: *jsruntime.Loop,
|
||||
) !void {
|
||||
self.session.deinit();
|
||||
try Session.init(&self.session, alloc, loop, uri);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "Browser" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
// 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 {
|
||||
// allocator used to init the arena.
|
||||
alloc: std.mem.Allocator,
|
||||
|
||||
// this will crash if ICU isn't properly configured / ininitialized
|
||||
try runner.testCases(&.{
|
||||
.{ "new Intl.DateTimeFormat()", "[object Intl.DateTimeFormat]" },
|
||||
}, .{});
|
||||
}
|
||||
// The arena is used only to bound the js env init b/c it leaks memory.
|
||||
// see https://github.com/lightpanda-io/jsruntime-lib/issues/181
|
||||
//
|
||||
// The arena is initialised with self.alloc allocator.
|
||||
// all others Session deps use directly self.alloc and not the arena.
|
||||
arena: std.heap.ArenaAllocator,
|
||||
|
||||
uri: []const u8,
|
||||
|
||||
// TODO handle proxy
|
||||
loader: Loader,
|
||||
env: Env = undefined,
|
||||
inspector: ?jsruntime.Inspector = null,
|
||||
window: Window,
|
||||
// TODO move the shed to the browser?
|
||||
storageShed: storage.Shed,
|
||||
page: ?Page = null,
|
||||
httpClient: HttpClient,
|
||||
|
||||
jstypes: [Types.len]usize = undefined,
|
||||
|
||||
fn init(self: *Session, alloc: std.mem.Allocator, loop: *Loop, uri: []const u8) !void {
|
||||
self.* = Session{
|
||||
.uri = uri,
|
||||
.alloc = alloc,
|
||||
.arena = std.heap.ArenaAllocator.init(alloc),
|
||||
.window = Window.create(null, .{ .agent = user_agent }),
|
||||
.loader = Loader.init(alloc),
|
||||
.storageShed = storage.Shed.init(alloc),
|
||||
.httpClient = undefined,
|
||||
};
|
||||
|
||||
Env.init(&self.env, self.arena.allocator(), loop, null);
|
||||
self.httpClient = .{ .allocator = alloc };
|
||||
try self.env.load(&self.jstypes);
|
||||
}
|
||||
|
||||
fn fetchModule(ctx: *anyopaque, referrer: ?jsruntime.Module, specifier: []const u8) !jsruntime.Module {
|
||||
_ = referrer;
|
||||
|
||||
const self: *Session = @ptrCast(@alignCast(ctx));
|
||||
|
||||
if (self.page == null) return error.NoPage;
|
||||
|
||||
log.debug("fetch module: specifier: {s}", .{specifier});
|
||||
const alloc = self.arena.allocator();
|
||||
const body = try self.page.?.fetchData(alloc, specifier);
|
||||
defer alloc.free(body);
|
||||
|
||||
return self.env.compileModule(body, specifier);
|
||||
}
|
||||
|
||||
fn deinit(self: *Session) void {
|
||||
if (self.page) |*p| p.end();
|
||||
|
||||
if (self.inspector) |inspector| {
|
||||
inspector.deinit(self.alloc);
|
||||
}
|
||||
|
||||
self.env.deinit();
|
||||
self.arena.deinit();
|
||||
|
||||
self.httpClient.deinit();
|
||||
self.loader.deinit();
|
||||
self.storageShed.deinit();
|
||||
}
|
||||
|
||||
pub fn initInspector(
|
||||
self: *Session,
|
||||
ctx: anytype,
|
||||
onResp: jsruntime.InspectorOnResponseFn,
|
||||
onEvent: jsruntime.InspectorOnEventFn,
|
||||
) !void {
|
||||
const ctx_opaque = @as(*anyopaque, @ptrCast(ctx));
|
||||
self.inspector = try jsruntime.Inspector.init(self.alloc, self.env, ctx_opaque, onResp, onEvent);
|
||||
self.env.setInspector(self.inspector.?);
|
||||
}
|
||||
|
||||
pub fn callInspector(self: *Session, msg: []const u8) void {
|
||||
if (self.inspector) |inspector| {
|
||||
inspector.send(msg, self.env);
|
||||
} else {
|
||||
@panic("No Inspector");
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (self.page != null) return error.SessionPageExists;
|
||||
const p: Page = undefined;
|
||||
self.page = p;
|
||||
Page.init(&self.page.?, self.alloc, self);
|
||||
return &self.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 {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
session: *Session,
|
||||
doc: ?*parser.Document = null,
|
||||
|
||||
// handle url
|
||||
rawuri: ?[]const u8 = null,
|
||||
uri: std.Uri = undefined,
|
||||
origin: ?[]const u8 = null,
|
||||
|
||||
raw_data: ?[]const u8 = null,
|
||||
|
||||
fn init(
|
||||
self: *Page,
|
||||
alloc: std.mem.Allocator,
|
||||
session: *Session,
|
||||
) void {
|
||||
self.* = .{
|
||||
.arena = std.heap.ArenaAllocator.init(alloc),
|
||||
.session = session,
|
||||
};
|
||||
}
|
||||
|
||||
// start js env.
|
||||
// - auxData: extra data forwarded to the Inspector
|
||||
// see Inspector.contextCreated
|
||||
pub fn start(self: *Page, auxData: ?[]const u8) !void {
|
||||
// start JS env
|
||||
log.debug("start js env", .{});
|
||||
try self.session.env.start();
|
||||
|
||||
// register the module loader
|
||||
try self.session.env.setModuleLoadFn(self.session, Session.fetchModule);
|
||||
|
||||
// add global objects
|
||||
log.debug("setup global env", .{});
|
||||
try self.session.env.bindGlobal(&self.session.window);
|
||||
|
||||
// load polyfills
|
||||
try polyfill.load(self.arena.allocator(), self.session.env);
|
||||
|
||||
// inspector
|
||||
if (self.session.inspector) |inspector| {
|
||||
log.debug("inspector context created", .{});
|
||||
inspector.contextCreated(self.session.env, "", self.origin orelse "://", auxData);
|
||||
}
|
||||
}
|
||||
|
||||
// reset js env and mem arena.
|
||||
pub fn end(self: *Page) void {
|
||||
self.session.env.stop();
|
||||
// TODO unload document: https://html.spec.whatwg.org/#unloading-documents
|
||||
|
||||
// clear netsurf memory arena.
|
||||
parser.deinit();
|
||||
|
||||
_ = self.arena.reset(.free_all);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Page) void {
|
||||
self.arena.deinit();
|
||||
self.session.page = null;
|
||||
}
|
||||
|
||||
// dump writes the page content into the given file.
|
||||
pub fn dump(self: *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 wait(self: *Page) !void {
|
||||
|
||||
// try catch
|
||||
var try_catch: jsruntime.TryCatch = undefined;
|
||||
try_catch.init(self.session.env);
|
||||
defer try_catch.deinit();
|
||||
|
||||
self.session.env.wait() catch |err| {
|
||||
// the js env could not be started if the document wasn't an HTML.
|
||||
if (err == error.EnvNotStarted) return;
|
||||
|
||||
const alloc = self.arena.allocator();
|
||||
if (try try_catch.err(alloc, self.session.env)) |msg| {
|
||||
defer alloc.free(msg);
|
||||
log.info("wait error: {s}", .{msg});
|
||||
return;
|
||||
}
|
||||
};
|
||||
log.debug("wait: OK", .{});
|
||||
}
|
||||
|
||||
// spec reference: https://html.spec.whatwg.org/#document-lifecycle
|
||||
// - auxData: extra data forwarded to the Inspector
|
||||
// see Inspector.contextCreated
|
||||
pub fn navigate(self: *Page, uri: []const u8, auxData: ?[]const u8) !void {
|
||||
const alloc = self.arena.allocator();
|
||||
|
||||
log.debug("starting GET {s}", .{uri});
|
||||
|
||||
// if the uri is about:blank, nothing to do.
|
||||
if (std.mem.eql(u8, "about:blank", uri)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// own the url
|
||||
if (self.rawuri) |prev| alloc.free(prev);
|
||||
self.rawuri = try alloc.dupe(u8, uri);
|
||||
self.uri = std.Uri.parse(self.rawuri.?) catch try std.Uri.parseAfterScheme("", self.rawuri.?);
|
||||
|
||||
// prepare origin value.
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
try self.uri.writeToStream(.{
|
||||
.scheme = true,
|
||||
.authority = true,
|
||||
}, buf.writer());
|
||||
self.origin = try buf.toOwnedSlice();
|
||||
|
||||
// TODO handle fragment in url.
|
||||
|
||||
// load the data
|
||||
var resp = try self.session.loader.get(alloc, self.uri);
|
||||
defer resp.deinit();
|
||||
|
||||
const req = resp.req;
|
||||
|
||||
log.info("GET {any} {d}", .{ self.uri, @intFromEnum(req.response.status) });
|
||||
|
||||
// TODO handle redirection
|
||||
log.debug("{?} {d} {s}", .{
|
||||
req.response.version,
|
||||
@intFromEnum(req.response.status),
|
||||
req.response.reason,
|
||||
// TODO log headers
|
||||
});
|
||||
|
||||
// TODO handle charset
|
||||
// https://html.spec.whatwg.org/#content-type
|
||||
var it = req.response.iterateHeaders();
|
||||
var ct: ?[]const u8 = null;
|
||||
while (true) {
|
||||
const h = it.next() orelse break;
|
||||
if (std.ascii.eqlIgnoreCase(h.name, "Content-Type")) {
|
||||
ct = try alloc.dupe(u8, h.value);
|
||||
}
|
||||
}
|
||||
if (ct == null) {
|
||||
// no content type in HTTP headers.
|
||||
// TODO try to sniff mime type from the body.
|
||||
log.info("no content-type HTTP header", .{});
|
||||
return;
|
||||
}
|
||||
defer alloc.free(ct.?);
|
||||
|
||||
log.debug("header content-type: {s}", .{ct.?});
|
||||
const mime = try Mime.parse(ct.?);
|
||||
if (mime.eql(Mime.HTML)) {
|
||||
try self.loadHTMLDoc(req.reader(), mime.charset orelse "utf-8", auxData);
|
||||
} else {
|
||||
log.info("non-HTML document: {s}", .{ct.?});
|
||||
|
||||
// save the body into the page.
|
||||
self.raw_data = try req.reader().readAllAlloc(alloc, 16 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
fn loadHTMLDoc(self: *Page, reader: anytype, charset: []const u8, auxData: ?[]const u8) !void {
|
||||
const alloc = self.arena.allocator();
|
||||
|
||||
// start netsurf memory arena.
|
||||
try parser.init();
|
||||
|
||||
log.debug("parse html with charset {s}", .{charset});
|
||||
|
||||
const ccharset = try alloc.dupeZ(u8, charset);
|
||||
defer alloc.free(ccharset);
|
||||
|
||||
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;
|
||||
|
||||
// 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.rawuri orelse "about:blank");
|
||||
|
||||
// TODO set the referrer to the document.
|
||||
|
||||
self.session.window.replaceDocument(html_doc);
|
||||
self.session.window.setStorageShelf(
|
||||
try self.session.storageShed.getOrPut(self.origin orelse "null"),
|
||||
);
|
||||
|
||||
// https://html.spec.whatwg.org/#read-html
|
||||
|
||||
// inspector
|
||||
if (self.session.inspector) |inspector| {
|
||||
inspector.contextCreated(self.session.env, "", self.origin.?, auxData);
|
||||
}
|
||||
|
||||
// replace the user context document with the new one.
|
||||
try self.session.env.setUserContext(.{
|
||||
.document = html_doc,
|
||||
.httpClient = &self.session.httpClient,
|
||||
});
|
||||
|
||||
// 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.ArrayList(Script).init(alloc);
|
||||
defer sasync.deinit();
|
||||
|
||||
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.isasync) {
|
||||
try sasync.append(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.session.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: Script) !void {
|
||||
const alloc = self.arena.allocator();
|
||||
|
||||
// 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(alloc, self.session.env, 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,
|
||||
};
|
||||
|
||||
// the caller owns the returned string
|
||||
fn fetchData(self: *Page, alloc: std.mem.Allocator, src: []const u8) ![]const u8 {
|
||||
log.debug("starting fetch {s}", .{src});
|
||||
|
||||
var buffer: [1024]u8 = undefined;
|
||||
var b: []u8 = buffer[0..];
|
||||
const u = try std.Uri.resolve_inplace(self.uri, src, &b);
|
||||
|
||||
var fetchres = try self.session.loader.get(alloc, u);
|
||||
defer fetchres.deinit();
|
||||
|
||||
const resp = fetchres.req.response;
|
||||
|
||||
log.info("fetch {any}: {d}", .{ u, resp.status });
|
||||
|
||||
if (resp.status != .ok) return FetchError.BadStatusCode;
|
||||
|
||||
// TODO check content-type
|
||||
const body = try fetchres.req.reader().readAllAlloc(alloc, 16 * 1024 * 1024);
|
||||
|
||||
// check no body
|
||||
if (body.len == 0) return FetchError.NoBody;
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
// fetchScript senf a GET request to the src and execute the script
|
||||
// received.
|
||||
fn fetchScript(self: *Page, s: Script) !void {
|
||||
const alloc = self.arena.allocator();
|
||||
const body = try self.fetchData(alloc, s.src);
|
||||
defer alloc.free(body);
|
||||
|
||||
try s.eval(alloc, self.session.env, body);
|
||||
}
|
||||
|
||||
const Script = struct {
|
||||
element: *parser.Element,
|
||||
kind: Kind,
|
||||
isasync: 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 = kind(try parser.elementGetAttribute(e, "type")),
|
||||
.isasync = 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 kind(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.?, "module")) return .module;
|
||||
|
||||
return .unknown;
|
||||
}
|
||||
|
||||
fn eval(self: Script, alloc: std.mem.Allocator, env: Env, body: []const u8) !void {
|
||||
var try_catch: jsruntime.TryCatch = undefined;
|
||||
try_catch.init(env);
|
||||
defer try_catch.deinit();
|
||||
|
||||
const res = switch (self.kind) {
|
||||
.unknown => return error.UnknownScript,
|
||||
.javascript => env.exec(body, self.src),
|
||||
.module => env.module(body, self.src),
|
||||
} catch {
|
||||
if (try try_catch.err(alloc, env)) |msg| {
|
||||
defer alloc.free(msg);
|
||||
log.info("eval script {s}: {s}", .{ self.src, msg });
|
||||
}
|
||||
return FetchError.JsErr;
|
||||
};
|
||||
|
||||
if (builtin.mode == .Debug) {
|
||||
const msg = try res.toString(alloc, env);
|
||||
defer alloc.free(msg);
|
||||
log.debug("eval script {s}: {s}", .{ self.src, msg });
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,329 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
const JsObject = @import("../env.zig").Env.JsObject;
|
||||
|
||||
const log = if (builtin.is_test) &test_capture else @import("../../log.zig");
|
||||
|
||||
pub const Console = struct {
|
||||
// TODO: configurable writer
|
||||
timers: std.StringHashMapUnmanaged(u32) = .{},
|
||||
counts: std.StringHashMapUnmanaged(u32) = .{},
|
||||
|
||||
pub fn _lp(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn _log(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.info(.console, "info", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn _info(values: []JsObject, page: *Page) !void {
|
||||
return _log(values, page);
|
||||
}
|
||||
|
||||
pub fn _debug(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.debug(.console, "debug", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn _warn(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.warn(.console, "warn", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn _error(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(.console, "error", .{
|
||||
.args = try serializeValues(values, page),
|
||||
.stack = page.stackTrace() catch "???",
|
||||
});
|
||||
}
|
||||
|
||||
pub fn _clear() void {}
|
||||
|
||||
pub fn _count(self: *Console, label_: ?[]const u8, page: *Page) !void {
|
||||
const label = label_ orelse "default";
|
||||
const gop = try self.counts.getOrPut(page.arena, label);
|
||||
|
||||
var current: u32 = 0;
|
||||
if (gop.found_existing) {
|
||||
current = gop.value_ptr.*;
|
||||
} else {
|
||||
gop.key_ptr.* = try page.arena.dupe(u8, label);
|
||||
}
|
||||
|
||||
const count = current + 1;
|
||||
gop.value_ptr.* = count;
|
||||
|
||||
log.info(.console, "count", .{ .label = label, .count = count });
|
||||
}
|
||||
|
||||
pub fn _countReset(self: *Console, label_: ?[]const u8) !void {
|
||||
const label = label_ orelse "default";
|
||||
const kv = self.counts.fetchRemove(label) orelse {
|
||||
log.info(.console, "invalid counter", .{ .label = label });
|
||||
return;
|
||||
};
|
||||
log.info(.console, "count reset", .{ .label = label, .count = kv.value });
|
||||
}
|
||||
|
||||
pub fn _time(self: *Console, label_: ?[]const u8, page: *Page) !void {
|
||||
const label = label_ orelse "default";
|
||||
const gop = try self.timers.getOrPut(page.arena, label);
|
||||
|
||||
if (gop.found_existing) {
|
||||
log.info(.console, "duplicate timer", .{ .label = label });
|
||||
return;
|
||||
}
|
||||
gop.key_ptr.* = try page.arena.dupe(u8, label);
|
||||
gop.value_ptr.* = timestamp();
|
||||
}
|
||||
|
||||
pub fn _timeLog(self: *Console, label_: ?[]const u8) void {
|
||||
const elapsed = timestamp();
|
||||
const label = label_ orelse "default";
|
||||
const start = self.timers.get(label) orelse {
|
||||
log.info(.console, "invalid timer", .{ .label = label });
|
||||
return;
|
||||
};
|
||||
log.info(.console, "timer", .{ .label = label, .elapsed = elapsed - start });
|
||||
}
|
||||
|
||||
pub fn _timeStop(self: *Console, label_: ?[]const u8) void {
|
||||
const elapsed = timestamp();
|
||||
const label = label_ orelse "default";
|
||||
const kv = self.timers.fetchRemove(label) orelse {
|
||||
log.info(.console, "invalid timer", .{ .label = label });
|
||||
return;
|
||||
};
|
||||
|
||||
log.warn(.console, "timer stop", .{ .label = label, .elapsed = elapsed - kv.value });
|
||||
}
|
||||
|
||||
pub fn _assert(assertion: JsObject, values: []JsObject, page: *Page) !void {
|
||||
if (assertion.isTruthy()) {
|
||||
return;
|
||||
}
|
||||
var serialized_values: []const u8 = "";
|
||||
if (values.len > 0) {
|
||||
serialized_values = try serializeValues(values, page);
|
||||
}
|
||||
log.info(.console, "assertion failed", .{ .values = serialized_values });
|
||||
}
|
||||
|
||||
fn serializeValues(values: []JsObject, page: *Page) ![]const u8 {
|
||||
if (values.len == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const arena = page.call_arena;
|
||||
const separator = log.separator();
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
|
||||
for (values, 1..) |value, i| {
|
||||
try arr.appendSlice(arena, separator);
|
||||
try arr.writer(arena).print("{d}: ", .{i});
|
||||
const serialized = if (builtin.mode == .Debug) value.toDetailString() else value.toString();
|
||||
try arr.appendSlice(arena, try serialized);
|
||||
}
|
||||
return arr.items;
|
||||
}
|
||||
};
|
||||
|
||||
fn timestamp() u32 {
|
||||
const ts = std.posix.clock_gettime(std.posix.CLOCK.MONOTONIC) catch unreachable;
|
||||
return @intCast(ts.sec);
|
||||
}
|
||||
|
||||
var test_capture = TestCapture{};
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Console" {
|
||||
defer testing.reset();
|
||||
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
{
|
||||
try runner.testCases(&.{
|
||||
.{ "console.log('a')", "undefined" },
|
||||
.{ "console.warn('hello world', 23, true, new Object())", "undefined" },
|
||||
}, .{});
|
||||
|
||||
const captured = test_capture.captured.items;
|
||||
try testing.expectEqual("[info] args= 1: a", captured[0]);
|
||||
try testing.expectEqual("[warn] args= 1: hello world 2: 23 3: true 4: #<Object>", captured[1]);
|
||||
}
|
||||
|
||||
{
|
||||
test_capture.reset();
|
||||
try runner.testCases(&.{
|
||||
.{ "console.countReset()", "undefined" },
|
||||
.{ "console.count()", "undefined" },
|
||||
.{ "console.count('teg')", "undefined" },
|
||||
.{ "console.count('teg')", "undefined" },
|
||||
.{ "console.count('teg')", "undefined" },
|
||||
.{ "console.count()", "undefined" },
|
||||
.{ "console.countReset('teg')", "undefined" },
|
||||
.{ "console.countReset()", "undefined" },
|
||||
.{ "console.count()", "undefined" },
|
||||
}, .{});
|
||||
|
||||
const captured = test_capture.captured.items;
|
||||
try testing.expectEqual("[invalid counter] label=default", captured[0]);
|
||||
try testing.expectEqual("[count] label=default count=1", captured[1]);
|
||||
try testing.expectEqual("[count] label=teg count=1", captured[2]);
|
||||
try testing.expectEqual("[count] label=teg count=2", captured[3]);
|
||||
try testing.expectEqual("[count] label=teg count=3", captured[4]);
|
||||
try testing.expectEqual("[count] label=default count=2", captured[5]);
|
||||
try testing.expectEqual("[count reset] label=teg count=3", captured[6]);
|
||||
try testing.expectEqual("[count reset] label=default count=2", captured[7]);
|
||||
try testing.expectEqual("[count] label=default count=1", captured[8]);
|
||||
}
|
||||
|
||||
{
|
||||
test_capture.reset();
|
||||
try runner.testCases(&.{
|
||||
.{ "console.assert(true)", "undefined" },
|
||||
.{ "console.assert('a', 2, 3, 4)", "undefined" },
|
||||
.{ "console.assert('')", "undefined" },
|
||||
.{ "console.assert('', 'x', true)", "undefined" },
|
||||
.{ "console.assert(false, 'x')", "undefined" },
|
||||
}, .{});
|
||||
|
||||
const captured = test_capture.captured.items;
|
||||
try testing.expectEqual("[assertion failed] values=", captured[0]);
|
||||
try testing.expectEqual("[assertion failed] values= 1: x 2: true", captured[1]);
|
||||
try testing.expectEqual("[assertion failed] values= 1: x", captured[2]);
|
||||
}
|
||||
|
||||
{
|
||||
test_capture.reset();
|
||||
try runner.testCases(&.{
|
||||
.{ "[1].forEach(console.log)", null },
|
||||
}, .{});
|
||||
|
||||
const captured = test_capture.captured.items;
|
||||
try testing.expectEqual("[info] args= 1: 1 2: 0 3: [1]", captured[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const TestCapture = struct {
|
||||
captured: std.ArrayListUnmanaged([]const u8) = .{},
|
||||
|
||||
fn separator(_: *const TestCapture) []const u8 {
|
||||
return " ";
|
||||
}
|
||||
|
||||
fn reset(self: *TestCapture) void {
|
||||
self.captured = .{};
|
||||
}
|
||||
|
||||
fn debug(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self.capture(scope, msg, args);
|
||||
}
|
||||
|
||||
fn info(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self.capture(scope, msg, args);
|
||||
}
|
||||
|
||||
fn warn(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self.capture(scope, msg, args);
|
||||
}
|
||||
|
||||
fn err(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self.capture(scope, msg, args);
|
||||
}
|
||||
|
||||
fn fatal(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self.capture(scope, msg, args);
|
||||
}
|
||||
|
||||
fn capture(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) void {
|
||||
self._capture(scope, msg, args) catch unreachable;
|
||||
}
|
||||
|
||||
fn _capture(
|
||||
self: *TestCapture,
|
||||
comptime scope: @Type(.enum_literal),
|
||||
comptime msg: []const u8,
|
||||
args: anytype,
|
||||
) !void {
|
||||
std.debug.assert(scope == .console);
|
||||
|
||||
const allocator = testing.arena_allocator;
|
||||
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try buf.appendSlice(allocator, "[" ++ msg ++ "] ");
|
||||
|
||||
inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| {
|
||||
try buf.appendSlice(allocator, f.name);
|
||||
try buf.append(allocator, '=');
|
||||
try @import("../../log.zig").writeValue(.pretty, @field(args, f.name), buf.writer(allocator));
|
||||
try buf.append(allocator, ' ');
|
||||
}
|
||||
self.captured.append(testing.arena_allocator, std.mem.trimRight(u8, buf.items, " ")) catch unreachable;
|
||||
}
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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;
|
||||
const uuidv4 = @import("../../id.zig").uuidv4;
|
||||
|
||||
// https://w3c.github.io/webcrypto/#crypto-interface
|
||||
pub const Crypto = struct {
|
||||
_not_empty: bool = true,
|
||||
|
||||
pub fn _getRandomValues(_: *const Crypto, js_obj: Env.JsObject) !Env.JsObject {
|
||||
var into = try js_obj.toZig(Crypto, "getRandomValues", RandomValues);
|
||||
const buf = into.asBuffer();
|
||||
if (buf.len > 65_536) {
|
||||
return error.QuotaExceededError;
|
||||
}
|
||||
std.crypto.random.bytes(buf);
|
||||
return js_obj;
|
||||
}
|
||||
|
||||
pub fn _randomUUID(_: *const Crypto) [36]u8 {
|
||||
var hex: [36]u8 = undefined;
|
||||
uuidv4(&hex);
|
||||
return hex;
|
||||
}
|
||||
};
|
||||
|
||||
const RandomValues = union(enum) {
|
||||
int8: []i8,
|
||||
uint8: []u8,
|
||||
int16: []i16,
|
||||
uint16: []u16,
|
||||
int32: []i32,
|
||||
uint32: []u32,
|
||||
int64: []i64,
|
||||
uint64: []u64,
|
||||
|
||||
fn asBuffer(self: RandomValues) []u8 {
|
||||
return switch (self) {
|
||||
.int8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.uint8 => |b| (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.int16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.uint16 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.int32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.uint32 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.int64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
.uint64 => |b| (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Crypto" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const a = crypto.randomUUID();", "undefined" },
|
||||
.{ "const b = crypto.randomUUID();", "undefined" },
|
||||
.{ "a.length;", "36" },
|
||||
.{ "b.length;", "36" },
|
||||
.{ "a == b;", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "try { crypto.getRandomValues(new BigUint64Array(8193)) } catch(e) { e.message == 'QuotaExceededError' }", "true" },
|
||||
.{ "let r1 = new Int32Array(5)", "undefined" },
|
||||
.{ "let r2 = crypto.getRandomValues(r1)", "undefined" },
|
||||
.{ "new Set(r1).size", "5" },
|
||||
.{ "new Set(r2).size", "5" },
|
||||
.{ "r1.every((v, i) => v === r2[i])", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var r3 = new Uint8Array(16)", null },
|
||||
.{ "let r4 = crypto.getRandomValues(r3)", "undefined" },
|
||||
.{ "r4[6] = 10", null },
|
||||
.{ "r4[6]", "10" },
|
||||
.{ "r3[6]", "10" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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 StyleSheet = @import("stylesheet.zig").StyleSheet;
|
||||
const CSSRule = @import("css_rule.zig").CSSRule;
|
||||
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
|
||||
|
||||
pub const CSSRuleList = struct {
|
||||
list: std.ArrayListUnmanaged([]const u8),
|
||||
|
||||
pub fn constructor() CSSRuleList {
|
||||
return .{ .list = .empty };
|
||||
}
|
||||
|
||||
pub fn _item(self: *CSSRuleList, _index: u32) ?CSSRule {
|
||||
const index: usize = @intCast(_index);
|
||||
|
||||
if (index > self.list.items.len) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// todo: for now, just return null.
|
||||
// this depends on properly parsing CSSRule
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn get_length(self: *CSSRuleList) u32 {
|
||||
return @intCast(self.list.items.len);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.CSS.CSSRuleList" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let list = new CSSRuleList()", "undefined" },
|
||||
.{ "list instanceof CSSRuleList", "true" },
|
||||
.{ "list.length", "0" },
|
||||
.{ "list.item(0)", "null" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
// 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 CSSRule = @import("css_rule.zig").CSSRule;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
pub const CSSStyleDeclaration = struct {
|
||||
store: std.StringHashMapUnmanaged(Property),
|
||||
order: std.ArrayListUnmanaged([]const u8),
|
||||
|
||||
pub const empty: CSSStyleDeclaration = .{
|
||||
.store = .empty,
|
||||
.order = .empty,
|
||||
};
|
||||
|
||||
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, page: *Page) !void {
|
||||
const final_value = value orelse "";
|
||||
return self._setProperty("float", final_value, null, page);
|
||||
}
|
||||
|
||||
pub fn get_cssText(self: *const CSSStyleDeclaration, page: *Page) ![]const u8 {
|
||||
var buffer: std.ArrayListUnmanaged(u8) = .empty;
|
||||
const writer = buffer.writer(page.call_arena);
|
||||
for (self.order.items) |name| {
|
||||
const prop = self.store.get(name).?;
|
||||
const escaped = try CSSValueAnalyzer.escapeCSSValue(page.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, page: *Page) !void {
|
||||
self.store.clearRetainingCapacity();
|
||||
self.order.clearRetainingCapacity();
|
||||
|
||||
// call_arena is safe here, because _setProperty will dupe the name
|
||||
// using the page's longer-living arena.
|
||||
const declarations = try CSSParser.parseDeclarations(page.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, page);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_length(self: *const CSSStyleDeclaration) usize {
|
||||
return self.order.items.len;
|
||||
}
|
||||
|
||||
pub fn get_parentRule(_: *const CSSStyleDeclaration) ?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 {
|
||||
if (self.store.get(name)) |prop| {
|
||||
return prop.value;
|
||||
}
|
||||
|
||||
// default to everything being visible (unless it's been explicitly set)
|
||||
if (std.mem.eql(u8, name, "visibility")) {
|
||||
return "visible";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
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 page.arena
|
||||
return prop.value.value;
|
||||
}
|
||||
|
||||
pub fn _setProperty(self: *CSSStyleDeclaration, name: []const u8, value: []const u8, priority: ?[]const u8, page: *Page) !void {
|
||||
const owned_value = try page.arena.dupe(u8, value);
|
||||
const is_important = priority != null and std.ascii.eqlIgnoreCase(priority.?, "important");
|
||||
|
||||
const gop = try self.store.getOrPut(page.arena, name);
|
||||
if (!gop.found_existing) {
|
||||
const owned_name = try page.arena.dupe(u8, name);
|
||||
gop.key_ptr.* = owned_name;
|
||||
try self.order.append(page.arena, owned_name);
|
||||
}
|
||||
|
||||
gop.value_ptr.* = .{ .value = owned_value, .priority = is_important };
|
||||
}
|
||||
|
||||
pub fn named_get(self: *const CSSStyleDeclaration, name: []const u8, _: *bool) []const u8 {
|
||||
return self._getPropertyValue(name);
|
||||
}
|
||||
};
|
||||
|
||||
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.color", "green" },
|
||||
|
||||
.{ "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" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "style.visibility", "visible" },
|
||||
.{ "style.getPropertyValue('visibility')", "visible" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
const StyleSheet = @import("stylesheet.zig").StyleSheet;
|
||||
|
||||
const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
|
||||
const CSSImportRule = @import("css_rule.zig").CSSImportRule;
|
||||
|
||||
pub const CSSStyleSheet = struct {
|
||||
pub const prototype = *StyleSheet;
|
||||
|
||||
proto: StyleSheet,
|
||||
css_rules: CSSRuleList,
|
||||
owner_rule: ?*CSSImportRule,
|
||||
|
||||
const CSSStyleSheetOpts = struct {
|
||||
base_url: ?[]const u8 = null,
|
||||
// TODO: Suupport media
|
||||
disabled: bool = false,
|
||||
};
|
||||
|
||||
pub fn constructor(_opts: ?CSSStyleSheetOpts) !CSSStyleSheet {
|
||||
const opts = _opts orelse CSSStyleSheetOpts{};
|
||||
return .{
|
||||
.proto = StyleSheet{ .disabled = opts.disabled },
|
||||
.css_rules = .constructor(),
|
||||
.owner_rule = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_ownerRule(_: *CSSStyleSheet) ?*CSSImportRule {
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn get_cssRules(self: *CSSStyleSheet) *CSSRuleList {
|
||||
return &self.css_rules;
|
||||
}
|
||||
|
||||
pub fn _insertRule(self: *CSSStyleSheet, rule: []const u8, _index: ?usize, page: *Page) !usize {
|
||||
const index = _index orelse 0;
|
||||
if (index > self.css_rules.list.items.len) {
|
||||
return error.IndexSize;
|
||||
}
|
||||
|
||||
const arena = page.arena;
|
||||
try self.css_rules.list.insert(arena, index, try arena.dupe(u8, rule));
|
||||
return index;
|
||||
}
|
||||
|
||||
pub fn _deleteRule(self: *CSSStyleSheet, index: usize) !void {
|
||||
if (index > self.css_rules.list.items.len) {
|
||||
return error.IndexSize;
|
||||
}
|
||||
|
||||
_ = self.css_rules.list.orderedRemove(index);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.CSS.StyleSheet" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let css = new CSSStyleSheet()", "undefined" },
|
||||
.{ "css instanceof CSSStyleSheet", "true" },
|
||||
.{ "css.cssRules.length", "0" },
|
||||
.{ "css.ownerRule", "null" },
|
||||
.{ "let index1 = css.insertRule('body { color: red; }', 0)", "undefined" },
|
||||
.{ "index1", "0" },
|
||||
.{ "css.cssRules.length", "1" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,811 +0,0 @@
|
||||
// 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"));
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
pub const Stylesheet = @import("stylesheet.zig").StyleSheet;
|
||||
pub const CSSStylesheet = @import("css_stylesheet.zig").CSSStyleSheet;
|
||||
pub const CSSStyleDeclaration = @import("css_style_declaration.zig").CSSStyleDeclaration;
|
||||
pub const CSSRuleList = @import("css_rule_list.zig").CSSRuleList;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Stylesheet,
|
||||
CSSStylesheet,
|
||||
CSSStyleDeclaration,
|
||||
CSSRuleList,
|
||||
@import("css_rule.zig").Interfaces,
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/StyleSheet#specifications
|
||||
pub const StyleSheet = struct {
|
||||
disabled: bool = false,
|
||||
href: []const u8 = "",
|
||||
owner_node: ?*parser.Node = null,
|
||||
parent_stylesheet: ?*StyleSheet = null,
|
||||
title: []const u8 = "",
|
||||
type: []const u8 = "text/css",
|
||||
|
||||
pub fn get_disabled(self: *const StyleSheet) bool {
|
||||
return self.disabled;
|
||||
}
|
||||
|
||||
pub fn get_href(self: *const StyleSheet) []const u8 {
|
||||
return self.href;
|
||||
}
|
||||
|
||||
// TODO: media
|
||||
|
||||
pub fn get_ownerNode(self: *const StyleSheet) ?*parser.Node {
|
||||
return self.owner_node;
|
||||
}
|
||||
|
||||
pub fn get_parentStyleSheet(self: *const StyleSheet) ?*StyleSheet {
|
||||
return self.parent_stylesheet;
|
||||
}
|
||||
|
||||
pub fn get_title(self: *const StyleSheet) []const u8 {
|
||||
return self.title;
|
||||
}
|
||||
|
||||
pub fn get_type(self: *const StyleSheet) []const u8 {
|
||||
return self.type;
|
||||
}
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// Represents https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/data
|
||||
pub const DataURI = struct {
|
||||
was_base64_encoded: bool,
|
||||
// The contents in the uri. It will be base64 decoded but not prepared in
|
||||
// any way for mime.charset.
|
||||
data: []const u8,
|
||||
|
||||
// Parses data:[<media-type>][;base64],<data>
|
||||
pub fn parse(allocator: Allocator, src: []const u8) !?DataURI {
|
||||
if (!std.mem.startsWith(u8, src, "data:")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uri = src[5..];
|
||||
const data_starts = std.mem.indexOfScalar(u8, uri, ',') orelse return null;
|
||||
|
||||
// Extract the encoding.
|
||||
var metadata = uri[0..data_starts];
|
||||
var base64_encoded = false;
|
||||
if (std.mem.endsWith(u8, metadata, ";base64")) {
|
||||
base64_encoded = true;
|
||||
metadata = metadata[0 .. metadata.len - 7];
|
||||
}
|
||||
|
||||
// TODO: Extract mime type. This not trivial because Mime.parse requires
|
||||
// a []u8 and might mutate the src. And, the DataURI.parse references atm
|
||||
// do not have deinit calls.
|
||||
|
||||
// Prepare the data.
|
||||
var data = uri[data_starts + 1 ..];
|
||||
if (base64_encoded) {
|
||||
const decoder = std.base64.standard.Decoder;
|
||||
const decoded_size = try decoder.calcSizeForSlice(data);
|
||||
|
||||
const buffer = try allocator.alloc(u8, decoded_size);
|
||||
errdefer allocator.free(buffer);
|
||||
|
||||
try decoder.decode(buffer, data);
|
||||
data = buffer;
|
||||
}
|
||||
|
||||
return .{
|
||||
.was_base64_encoded = base64_encoded,
|
||||
.data = data,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const DataURI, allocator: Allocator) void {
|
||||
if (self.was_base64_encoded) {
|
||||
allocator.free(self.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
test "DataURI: parse valid" {
|
||||
try test_valid("data:text/javascript; charset=utf-8;base64,Zm9v", "foo");
|
||||
try test_valid("data:text/javascript; charset=utf-8;,foo", "foo");
|
||||
try test_valid("data:,foo", "foo");
|
||||
}
|
||||
|
||||
test "DataURI: parse invalid" {
|
||||
try test_cannot_parse("atad:,foo");
|
||||
try test_cannot_parse("data:foo");
|
||||
try test_cannot_parse("data:");
|
||||
}
|
||||
|
||||
fn test_valid(uri: []const u8, expected: []const u8) !void {
|
||||
const data_uri = try DataURI.parse(std.testing.allocator, uri) orelse return error.TestFailed;
|
||||
defer data_uri.deinit(testing.allocator);
|
||||
try testing.expectEqualStrings(expected, data_uri.data);
|
||||
}
|
||||
|
||||
fn test_cannot_parse(uri: []const u8) !void {
|
||||
try testing.expectEqual(null, DataURI.parse(std.testing.allocator, uri));
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Node = @import("node.zig").Node;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
const NodeUnion = @import("node.zig").Union;
|
||||
|
||||
const collection = @import("html_collection.zig");
|
||||
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 CSSStyleSheet = @import("../cssom/css_stylesheet.zig").CSSStyleSheet;
|
||||
const Range = @import("range.zig").Range;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
const DOMImplementation = @import("implementation.zig").DOMImplementation;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#document
|
||||
pub const Document = struct {
|
||||
pub const Self = parser.Document;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn constructor(page: *const Page) !*parser.DocumentHTML {
|
||||
const doc = try parser.documentCreateDocument(
|
||||
try parser.documentHTMLGetTitle(page.window.document),
|
||||
);
|
||||
|
||||
// we have to work w/ document instead of html document.
|
||||
const ddoc = parser.documentHTMLToDocument(doc);
|
||||
const ccur = parser.documentHTMLToDocument(page.window.document);
|
||||
try parser.documentSetDocumentURI(ddoc, try parser.documentGetDocumentURI(ccur));
|
||||
try parser.documentSetInputEncoding(ddoc, try parser.documentGetInputEncoding(ccur));
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
pub fn get_implementation(_: *parser.Document) DOMImplementation {
|
||||
return DOMImplementation{};
|
||||
}
|
||||
|
||||
pub fn get_documentElement(self: *parser.Document) !?ElementUnion {
|
||||
const e = try parser.documentGetDocumentElement(self);
|
||||
if (e == null) return null;
|
||||
return try Element.toInterface(e.?);
|
||||
}
|
||||
|
||||
pub fn get_documentURI(self: *parser.Document) ![]const u8 {
|
||||
return try parser.documentGetDocumentURI(self);
|
||||
}
|
||||
|
||||
pub fn get_URL(self: *parser.Document) ![]const u8 {
|
||||
return try get_documentURI(self);
|
||||
}
|
||||
|
||||
// TODO implement contentType
|
||||
pub fn get_contentType(self: *parser.Document) []const u8 {
|
||||
_ = self;
|
||||
return "text/html";
|
||||
}
|
||||
|
||||
// TODO implement compactMode
|
||||
pub fn get_compatMode(self: *parser.Document) []const u8 {
|
||||
_ = self;
|
||||
return "CSS1Compat";
|
||||
}
|
||||
|
||||
pub fn get_characterSet(self: *parser.Document) ![]const u8 {
|
||||
return try parser.documentGetInputEncoding(self);
|
||||
}
|
||||
|
||||
// alias of get_characterSet
|
||||
pub fn get_charset(self: *parser.Document) ![]const u8 {
|
||||
return try get_characterSet(self);
|
||||
}
|
||||
|
||||
// alias of get_characterSet
|
||||
pub fn get_inputEncoding(self: *parser.Document) ![]const u8 {
|
||||
return try get_characterSet(self);
|
||||
}
|
||||
|
||||
pub fn get_doctype(self: *parser.Document) !?*parser.DocumentType {
|
||||
return try parser.documentGetDoctype(self);
|
||||
}
|
||||
|
||||
pub fn _createEvent(_: *parser.Document, eventCstr: []const u8) !*parser.Event {
|
||||
// TODO: for now only "Event" constructor is supported
|
||||
// see table on https://dom.spec.whatwg.org/#dom-document-createevent $2
|
||||
if (std.ascii.eqlIgnoreCase(eventCstr, "Event") or std.ascii.eqlIgnoreCase(eventCstr, "Events")) {
|
||||
return try parser.eventCreate();
|
||||
}
|
||||
return parser.DOMError.NotSupported;
|
||||
}
|
||||
|
||||
pub fn _getElementById(self: *parser.Document, id: []const u8) !?ElementUnion {
|
||||
const e = try parser.documentGetElementById(self, id) orelse return null;
|
||||
return try Element.toInterface(e);
|
||||
}
|
||||
|
||||
pub fn _createElement(self: *parser.Document, tag_name: []const u8) !ElementUnion {
|
||||
// The element’s namespace is the HTML namespace when document is an HTML document
|
||||
// https://dom.spec.whatwg.org/#ref-for-dom-document-createelement%E2%91%A0
|
||||
const e = try parser.documentCreateElementNS(self, "http://www.w3.org/1999/xhtml", tag_name);
|
||||
return Element.toInterface(e);
|
||||
}
|
||||
|
||||
pub fn _createElementNS(self: *parser.Document, ns: []const u8, tag_name: []const u8) !ElementUnion {
|
||||
const e = try parser.documentCreateElementNS(self, ns, tag_name);
|
||||
return try Element.toInterface(e);
|
||||
}
|
||||
|
||||
// We can't simply use libdom dom_document_get_elements_by_tag_name here.
|
||||
// Indeed, netsurf implemented a previous dom spec when
|
||||
// getElementsByTagName returned a NodeList.
|
||||
// But since
|
||||
// https://github.com/whatwg/dom/commit/190700b7c12ecfd3b5ebdb359ab1d6ea9cbf7749
|
||||
// the spec changed to return an HTMLCollection instead.
|
||||
// That's why we reimplemented getElementsByTagName by using an
|
||||
// HTMLCollection in zig here.
|
||||
pub fn _getElementsByTagName(
|
||||
self: *parser.Document,
|
||||
tag_name: []const u8,
|
||||
page: *Page,
|
||||
) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(page.arena, parser.documentToNode(self), tag_name, true);
|
||||
}
|
||||
|
||||
pub fn _getElementsByClassName(
|
||||
self: *parser.Document,
|
||||
classNames: []const u8,
|
||||
page: *Page,
|
||||
) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByClassName(page.arena, parser.documentToNode(self), classNames, true);
|
||||
}
|
||||
|
||||
pub fn _createDocumentFragment(self: *parser.Document) !*parser.DocumentFragment {
|
||||
return try parser.documentCreateDocumentFragment(self);
|
||||
}
|
||||
|
||||
pub fn _createTextNode(self: *parser.Document, data: []const u8) !*parser.Text {
|
||||
return try parser.documentCreateTextNode(self, data);
|
||||
}
|
||||
|
||||
pub fn _createCDATASection(self: *parser.Document, data: []const u8) !*parser.CDATASection {
|
||||
return try parser.documentCreateCDATASection(self, data);
|
||||
}
|
||||
|
||||
pub fn _createComment(self: *parser.Document, data: []const u8) !*parser.Comment {
|
||||
return try parser.documentCreateComment(self, data);
|
||||
}
|
||||
|
||||
pub fn _createProcessingInstruction(self: *parser.Document, target: []const u8, data: []const u8) !*parser.ProcessingInstruction {
|
||||
return try parser.documentCreateProcessingInstruction(self, target, data);
|
||||
}
|
||||
|
||||
pub fn _importNode(self: *parser.Document, node: *parser.Node, deep: ?bool) !NodeUnion {
|
||||
const n = try parser.documentImportNode(self, node, deep orelse false);
|
||||
return try Node.toInterface(n);
|
||||
}
|
||||
|
||||
pub fn _adoptNode(self: *parser.Document, node: *parser.Node) !NodeUnion {
|
||||
const n = try parser.documentAdoptNode(self, node);
|
||||
return try Node.toInterface(n);
|
||||
}
|
||||
|
||||
pub fn _createAttribute(self: *parser.Document, name: []const u8) !*parser.Attribute {
|
||||
return try parser.documentCreateAttribute(self, name);
|
||||
}
|
||||
|
||||
pub fn _createAttributeNS(self: *parser.Document, ns: []const u8, qname: []const u8) !*parser.Attribute {
|
||||
return try parser.documentCreateAttributeNS(self, ns, qname);
|
||||
}
|
||||
|
||||
// ParentNode
|
||||
// https://dom.spec.whatwg.org/#parentnode
|
||||
pub fn get_children(self: *parser.Document) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionChildren(parser.documentToNode(self), false);
|
||||
}
|
||||
|
||||
pub fn get_firstElementChild(self: *parser.Document) !?ElementUnion {
|
||||
const elt = try parser.documentGetDocumentElement(self) orelse return null;
|
||||
return try Element.toInterface(elt);
|
||||
}
|
||||
|
||||
pub fn get_lastElementChild(self: *parser.Document) !?ElementUnion {
|
||||
const elt = try parser.documentGetDocumentElement(self) orelse return null;
|
||||
return try Element.toInterface(elt);
|
||||
}
|
||||
|
||||
pub fn get_childElementCount(self: *parser.Document) !u32 {
|
||||
_ = try parser.documentGetDocumentElement(self) orelse return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
pub fn _querySelector(self: *parser.Document, selector: []const u8, page: *Page) !?ElementUnion {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.call_arena, parser.documentToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
return try Element.toInterface(parser.nodeToElement(n.?));
|
||||
}
|
||||
|
||||
pub fn _querySelectorAll(self: *parser.Document, selector: []const u8, page: *Page) !NodeList {
|
||||
return css.querySelectorAll(page.arena, parser.documentToNode(self), selector);
|
||||
}
|
||||
|
||||
pub fn _prepend(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.prepend(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _append(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.append(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _replaceChildren(self: *parser.Document, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.replaceChildren(parser.documentToNode(self), nodes);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn getActiveElement(self: *parser.Document, page: *Page) !?*parser.Element {
|
||||
if (page.getNodeState(@alignCast(@ptrCast(self)))) |state| {
|
||||
if (state.active_element) |ae| {
|
||||
return ae;
|
||||
}
|
||||
}
|
||||
|
||||
if (try parser.documentHTMLBody(page.window.document)) |body| {
|
||||
return @alignCast(@ptrCast(body));
|
||||
}
|
||||
|
||||
return try parser.documentGetDocumentElement(self);
|
||||
}
|
||||
|
||||
pub fn get_activeElement(self: *parser.Document, page: *Page) !?ElementUnion {
|
||||
const ae = (try getActiveElement(self, page)) orelse return null;
|
||||
return try Element.toInterface(ae);
|
||||
}
|
||||
|
||||
// TODO: some elements can't be focused, like if they're disabled
|
||||
// but there doesn't seem to be a generic way to check this. For example
|
||||
// we could look for the "disabled" attribute, but that's only meaningful
|
||||
// on certain types, and libdom's vtable doesn't seem to expose this.
|
||||
pub fn setFocus(self: *parser.Document, e: *parser.ElementHTML, page: *Page) !void {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
state.active_element = @ptrCast(e);
|
||||
}
|
||||
|
||||
pub fn _createRange(_: *parser.Document, page: *Page) Range {
|
||||
return Range.constructor(page);
|
||||
}
|
||||
|
||||
// TODO: dummy implementation
|
||||
pub fn get_styleSheets(_: *parser.Document) []CSSStyleSheet {
|
||||
return &.{};
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Document" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{
|
||||
.url = "about:blank",
|
||||
});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.__proto__.__proto__.constructor.name", "Document" },
|
||||
.{ "document.__proto__.__proto__.__proto__.constructor.name", "Node" },
|
||||
.{ "document.__proto__.__proto__.__proto__.__proto__.constructor.name", "EventTarget" },
|
||||
|
||||
.{ "let newdoc = new Document()", "undefined" },
|
||||
.{ "newdoc.documentElement", "null" },
|
||||
.{ "newdoc.children.length", "0" },
|
||||
.{ "newdoc.getElementsByTagName('*').length", "0" },
|
||||
.{ "newdoc.getElementsByTagName('*').item(0)", "null" },
|
||||
.{ "newdoc.inputEncoding === document.inputEncoding", "true" },
|
||||
.{ "newdoc.documentURI === document.documentURI", "true" },
|
||||
.{ "newdoc.URL === document.URL", "true" },
|
||||
.{ "newdoc.compatMode === document.compatMode", "true" },
|
||||
.{ "newdoc.characterSet === document.characterSet", "true" },
|
||||
.{ "newdoc.charset === document.charset", "true" },
|
||||
.{ "newdoc.contentType === document.contentType", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let getElementById = document.getElementById('content')", "undefined" },
|
||||
.{ "getElementById.constructor.name", "HTMLDivElement" },
|
||||
.{ "getElementById.localName", "div" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
|
||||
.{ "getElementsByTagName.length", "2" },
|
||||
.{ "getElementsByTagName.item(0).localName", "p" },
|
||||
.{ "getElementsByTagName.item(1).localName", "p" },
|
||||
.{ "let getElementsByTagNameAll = document.getElementsByTagName('*')", "undefined" },
|
||||
.{ "getElementsByTagNameAll.length", "8" },
|
||||
.{ "getElementsByTagNameAll.item(0).localName", "html" },
|
||||
.{ "getElementsByTagNameAll.item(7).localName", "p" },
|
||||
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let ok = document.getElementsByClassName('ok')", "undefined" },
|
||||
.{ "ok.length", "2" },
|
||||
.{ "let empty = document.getElementsByClassName('empty')", "undefined" },
|
||||
.{ "empty.length", "1" },
|
||||
.{ "let emptyok = document.getElementsByClassName('empty ok')", "undefined" },
|
||||
.{ "emptyok.length", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let e = document.documentElement", "undefined" },
|
||||
.{ "e.localName", "html" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.characterSet", "UTF-8" },
|
||||
.{ "document.charset", "UTF-8" },
|
||||
.{ "document.inputEncoding", "UTF-8" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.compatMode", "CSS1Compat" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.contentType", "text/html" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.documentURI", "about:blank" },
|
||||
.{ "document.URL", "about:blank" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let impl = document.implementation", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let d = new Document()", "undefined" },
|
||||
.{ "d.characterSet", "UTF-8" },
|
||||
.{ "d.URL", "about:blank" },
|
||||
.{ "d.documentURI", "about:blank" },
|
||||
.{ "d.compatMode", "CSS1Compat" },
|
||||
.{ "d.contentType", "text/html" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var v = document.createDocumentFragment()", "undefined" },
|
||||
.{ "v.nodeName", "#document-fragment" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var v = document.createTextNode('foo')", "undefined" },
|
||||
.{ "v.nodeName", "#text" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var v = document.createCDATASection('foo')", "undefined" },
|
||||
.{ "v.nodeName", "#cdata-section" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var v = document.createComment('foo')", "undefined" },
|
||||
.{ "v.nodeName", "#comment" },
|
||||
.{ "let v2 = v.cloneNode()", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
|
||||
.{ "pi.target", "foo" },
|
||||
.{ "let pi2 = pi.cloneNode()", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let nimp = document.getElementById('content')", "undefined" },
|
||||
.{ "var v = document.importNode(nimp)", "undefined" },
|
||||
.{ "v.nodeName", "DIV" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var v = document.createAttribute('foo')", "undefined" },
|
||||
.{ "v.nodeName", "foo" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.children.length", "1" },
|
||||
.{ "document.children.item(0).nodeName", "HTML" },
|
||||
.{ "document.firstElementChild.nodeName", "HTML" },
|
||||
.{ "document.lastElementChild.nodeName", "HTML" },
|
||||
.{ "document.childElementCount", "1" },
|
||||
|
||||
.{ "let nd = new Document()", "undefined" },
|
||||
.{ "nd.children.length", "0" },
|
||||
.{ "nd.children.item(0)", "null" },
|
||||
.{ "nd.firstElementChild", "null" },
|
||||
.{ "nd.lastElementChild", "null" },
|
||||
.{ "nd.childElementCount", "0" },
|
||||
|
||||
.{ "let emptydoc = document.createElement('html')", "undefined" },
|
||||
.{ "emptydoc.prepend(document.createElement('html'))", "undefined" },
|
||||
|
||||
.{ "let emptydoc2 = document.createElement('html')", "undefined" },
|
||||
.{ "emptydoc2.append(document.createElement('html'))", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.querySelector('')", "null" },
|
||||
.{ "document.querySelector('*').nodeName", "HTML" },
|
||||
.{ "document.querySelector('#content').id", "content" },
|
||||
.{ "document.querySelector('#para').id", "para" },
|
||||
.{ "document.querySelector('.ok').id", "link" },
|
||||
.{ "document.querySelector('a ~ p').id", "para-empty" },
|
||||
.{ "document.querySelector(':root').nodeName", "HTML" },
|
||||
|
||||
.{ "document.querySelectorAll('p').length", "2" },
|
||||
.{
|
||||
\\ Array.from(document.querySelectorAll('#content > p#para-empty'))
|
||||
\\ .map(row => row.querySelector('span').textContent)
|
||||
\\ .length;
|
||||
,
|
||||
"1",
|
||||
},
|
||||
|
||||
.{ "document.querySelectorAll('.\\\\:popover-open').length", "0" },
|
||||
.{ "document.querySelectorAll('.foo\\\\:bar').length", "0" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.activeElement === document.body", "true" },
|
||||
.{ "document.getElementById('link').focus()", "undefined" },
|
||||
.{ "document.activeElement === document.getElementById('link')", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.styleSheets.length", "0" },
|
||||
}, .{});
|
||||
|
||||
// this test breaks the doc structure, keep it at the end of the test
|
||||
// suite.
|
||||
try runner.testCases(&.{
|
||||
.{ "let nadop = document.getElementById('content')", "undefined" },
|
||||
.{ "var v = document.adoptNode(nadop)", "undefined" },
|
||||
.{ "v.nodeName", "DIV" },
|
||||
}, .{});
|
||||
|
||||
const Case = testing.JsRunner.Case;
|
||||
const tags = comptime parser.Tag.all();
|
||||
var createElements: [(tags.len) * 2]Case = undefined;
|
||||
inline for (tags, 0..) |tag, i| {
|
||||
const tag_name = @tagName(tag);
|
||||
createElements[i * 2] = Case{
|
||||
"var " ++ tag_name ++ "Elem = document.createElement('" ++ tag_name ++ "')",
|
||||
"undefined",
|
||||
};
|
||||
createElements[(i * 2) + 1] = Case{
|
||||
tag_name ++ "Elem.localName",
|
||||
tag_name,
|
||||
};
|
||||
}
|
||||
try runner.testCases(&createElements, .{});
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// 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 css = @import("css.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
const Element = @import("element.zig").Element;
|
||||
const ElementUnion = @import("element.zig").Union;
|
||||
|
||||
const Node = @import("node.zig").Node;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#documentfragment
|
||||
pub const DocumentFragment = struct {
|
||||
pub const Self = parser.DocumentFragment;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn constructor(page: *const Page) !*parser.DocumentFragment {
|
||||
return parser.documentCreateDocumentFragment(
|
||||
parser.documentHTMLToDocument(page.window.document),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn _isEqualNode(self: *parser.DocumentFragment, other_node: *parser.Node) !bool {
|
||||
const other_type = try parser.nodeType(other_node);
|
||||
if (other_type != .document_fragment) {
|
||||
return false;
|
||||
}
|
||||
_ = 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);
|
||||
}
|
||||
|
||||
pub fn _querySelector(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !?ElementUnion {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.call_arena, parser.documentFragmentToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
return try Element.toInterface(parser.nodeToElement(n.?));
|
||||
}
|
||||
|
||||
pub fn _querySelectorAll(self: *parser.DocumentFragment, selector: []const u8, page: *Page) !NodeList {
|
||||
return css.querySelectorAll(page.arena, parser.documentFragmentToNode(self), selector);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.DocumentFragment" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const dc = new DocumentFragment()", "undefined" },
|
||||
.{ "dc.constructor.name", "DocumentFragment" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const dc1 = new DocumentFragment()", "undefined" },
|
||||
.{ "const dc2 = new DocumentFragment()", "undefined" },
|
||||
.{ "dc1.isEqualNode(dc1)", "true" },
|
||||
.{ "dc1.isEqualNode(dc2)", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let f = document.createDocumentFragment()", null },
|
||||
.{ "let d = document.createElement('div');", null },
|
||||
.{ "d.id = 'x';", null },
|
||||
.{ "document.getElementById('x') == null;", "true" },
|
||||
|
||||
.{ "f.append(d);", null },
|
||||
.{ "document.getElementById('x') == null;", "true" },
|
||||
|
||||
.{ "document.getElementsByTagName('body')[0].append(f.cloneNode(true));", null },
|
||||
.{ "document.getElementById('x') != null;", "true" },
|
||||
|
||||
.{ "document.querySelector('.hello')", "null" },
|
||||
.{ "document.querySelectorAll('.hello').length", "0" },
|
||||
|
||||
.{ "document.querySelector('#x').id", "x" },
|
||||
.{ "document.querySelectorAll('#x')[0].id", "x" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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 Node = @import("node.zig").Node;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#documenttype
|
||||
pub const DocumentType = struct {
|
||||
pub const Self = parser.DocumentType;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn get_name(self: *parser.DocumentType) ![]const u8 {
|
||||
return try parser.documentTypeGetName(self);
|
||||
}
|
||||
|
||||
pub fn get_publicId(self: *parser.DocumentType) ![]const u8 {
|
||||
return try parser.documentTypeGetPublicId(self);
|
||||
}
|
||||
|
||||
pub fn get_systemId(self: *parser.DocumentType) ![]const u8 {
|
||||
return try parser.documentTypeGetSystemId(self);
|
||||
}
|
||||
|
||||
// netsurf's DocumentType doesn't implement the dom_node_get_attributes
|
||||
// and thus will crash if we try to call nodeIsEqualNode.
|
||||
pub fn _isEqualNode(self: *parser.DocumentType, other_node: *parser.Node) !bool {
|
||||
if (try parser.nodeType(other_node) != .document_type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other: *parser.DocumentType = @ptrCast(other_node);
|
||||
if (std.mem.eql(u8, try get_name(self), try get_name(other)) == false) {
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, try get_publicId(self), try get_publicId(other)) == false) {
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, try get_systemId(self), try get_systemId(other)) == false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.DocumentType" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let dt1 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
|
||||
.{ "let dt2 = document.implementation.createDocumentType('qname2', 'pid2', 'sys2');", "undefined" },
|
||||
.{ "let dt3 = document.implementation.createDocumentType('qname1', 'pid1', 'sys1');", "undefined" },
|
||||
.{ "dt1.isEqualNode(dt1)", "true" },
|
||||
.{ "dt1.isEqualNode(dt3)", "true" },
|
||||
.{ "dt1.isEqualNode(dt2)", "false" },
|
||||
.{ "dt2.isEqualNode(dt3)", "false" },
|
||||
.{ "dt1.isEqualNode(document)", "false" },
|
||||
.{ "document.isEqualNode(dt1)", "false" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// 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]" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
const css = @import("css.zig");
|
||||
const log = @import("../../log.zig");
|
||||
const dump = @import("../dump.zig");
|
||||
const collection = @import("html_collection.zig");
|
||||
|
||||
const Node = @import("node.zig").Node;
|
||||
const Walker = @import("walker.zig").WalkerDepthFirst;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
const HTMLElem = @import("../html/elements.zig");
|
||||
const ShadowRoot = @import("../dom/shadow_root.zig").ShadowRoot;
|
||||
|
||||
pub const Union = @import("../html/elements.zig").Union;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#element
|
||||
pub const Element = struct {
|
||||
pub const Self = parser.Element;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub const DOMRect = struct {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
bottom: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
left: f64,
|
||||
};
|
||||
|
||||
pub fn toInterface(e: *parser.Element) !Union {
|
||||
return try HTMLElem.toInterface(Union, e);
|
||||
// SVGElement and MathML are not supported yet.
|
||||
}
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
pub fn get_namespaceURI(self: *parser.Element) !?[]const u8 {
|
||||
return try parser.nodeGetNamespace(parser.elementToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_prefix(self: *parser.Element) !?[]const u8 {
|
||||
return try parser.nodeGetPrefix(parser.elementToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_localName(self: *parser.Element) ![]const u8 {
|
||||
return try parser.nodeLocalName(parser.elementToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_tagName(self: *parser.Element) ![]const u8 {
|
||||
return try parser.nodeName(parser.elementToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_id(self: *parser.Element) ![]const u8 {
|
||||
return try parser.elementGetAttribute(self, "id") orelse "";
|
||||
}
|
||||
|
||||
pub fn set_id(self: *parser.Element, id: []const u8) !void {
|
||||
return try parser.elementSetAttribute(self, "id", id);
|
||||
}
|
||||
|
||||
pub fn get_className(self: *parser.Element) ![]const u8 {
|
||||
return try parser.elementGetAttribute(self, "class") orelse "";
|
||||
}
|
||||
|
||||
pub fn set_className(self: *parser.Element, class: []const u8) !void {
|
||||
return try parser.elementSetAttribute(self, "class", class);
|
||||
}
|
||||
|
||||
pub fn get_slot(self: *parser.Element) ![]const u8 {
|
||||
return try parser.elementGetAttribute(self, "slot") orelse "";
|
||||
}
|
||||
|
||||
pub fn set_slot(self: *parser.Element, slot: []const u8) !void {
|
||||
return try parser.elementSetAttribute(self, "slot", slot);
|
||||
}
|
||||
|
||||
pub fn get_classList(self: *parser.Element) !*parser.TokenList {
|
||||
return try parser.tokenListCreate(self, "class");
|
||||
}
|
||||
|
||||
pub fn get_attributes(self: *parser.Element) !*parser.NamedNodeMap {
|
||||
// An element must have non-nil attributes.
|
||||
return try parser.nodeGetAttributes(parser.elementToNode(self)) orelse unreachable;
|
||||
}
|
||||
|
||||
pub fn get_innerHTML(self: *parser.Element, page: *Page) ![]const u8 {
|
||||
var buf = std.ArrayList(u8).init(page.arena);
|
||||
try dump.writeChildren(parser.elementToNode(self), buf.writer());
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn get_outerHTML(self: *parser.Element, page: *Page) ![]const u8 {
|
||||
var buf = std.ArrayList(u8).init(page.arena);
|
||||
try dump.writeNode(parser.elementToNode(self), buf.writer());
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn set_innerHTML(self: *parser.Element, str: []const u8) !void {
|
||||
const node = parser.elementToNode(self);
|
||||
const doc = try parser.nodeOwnerDocument(node) orelse return parser.DOMError.WrongDocument;
|
||||
// parse the fragment
|
||||
const fragment = try parser.documentParseFragmentFromStr(doc, str);
|
||||
|
||||
// remove existing children
|
||||
try Node.removeChildren(node);
|
||||
|
||||
// I'm not sure what the exact behavior is supposed to be. Initially,
|
||||
// we were only copying the body of the document fragment. But it seems
|
||||
// like head elements should be copied too. Specifically, some sites
|
||||
// create script tags via innerHTML, which we need to capture.
|
||||
// If you play with this in a browser, you should notice that the
|
||||
// behavior is different depending on whether you're in a blank page
|
||||
// or an actual document. In a blank page, something like:
|
||||
// x.innerHTML = '<script></script>';
|
||||
// does _not_ create an empty script, but in a real page, it does. Weird.
|
||||
const fragment_node = parser.documentFragmentToNode(fragment);
|
||||
const html = try parser.nodeFirstChild(fragment_node) orelse return;
|
||||
const head = try parser.nodeFirstChild(html) orelse return;
|
||||
{
|
||||
// First, copy some of the head element
|
||||
const children = try parser.nodeGetChildNodes(head);
|
||||
const ln = try parser.nodeListLength(children);
|
||||
for (0..ln) |_| {
|
||||
// always index 0, because nodeAppendChild moves the node out of
|
||||
// the nodeList and into the new tree
|
||||
const child = try parser.nodeListItem(children, 0) orelse continue;
|
||||
_ = try parser.nodeAppendChild(node, child);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const body = try parser.nodeNextSibling(head) orelse return;
|
||||
const children = try parser.nodeGetChildNodes(body);
|
||||
const ln = try parser.nodeListLength(children);
|
||||
for (0..ln) |_| {
|
||||
// always index 0, because nodeAppendChild moves the node out of
|
||||
// the nodeList and into the new tree
|
||||
const child = try parser.nodeListItem(children, 0) orelse continue;
|
||||
_ = try parser.nodeAppendChild(node, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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, page: *Page) !?*parser.Element {
|
||||
const cssParse = @import("../css/css.zig").parse;
|
||||
const CssNodeWrap = @import("../css/libdom.zig").Node;
|
||||
const select = try cssParse(page.call_arena, selector, .{});
|
||||
|
||||
var current: CssNodeWrap = .{ .node = parser.elementToNode(self) };
|
||||
while (true) {
|
||||
if (try select.match(current)) {
|
||||
if (!current.isElement()) {
|
||||
log.err(.browser, "closest invalid type", .{ .type = try current.tag() });
|
||||
return null;
|
||||
}
|
||||
return parser.nodeToElement(current.node);
|
||||
}
|
||||
current = try current.parent() orelse return null;
|
||||
}
|
||||
}
|
||||
|
||||
// don't use parser.nodeHasAttributes(...) because that returns true/false
|
||||
// based on the type, e.g. a node never as attributes, an element always has
|
||||
// attributes. But, Element.hasAttributes is supposed to return true only
|
||||
// if the element has at least 1 attribute.
|
||||
pub fn _hasAttributes(self: *parser.Element) !bool {
|
||||
// an element _must_ have at least an empty attribute
|
||||
const node_map = try parser.nodeGetAttributes(parser.elementToNode(self)) orelse unreachable;
|
||||
return try parser.namedNodeMapGetLength(node_map) > 0;
|
||||
}
|
||||
|
||||
pub fn _getAttribute(self: *parser.Element, qname: []const u8) !?[]const u8 {
|
||||
return try parser.elementGetAttribute(self, qname);
|
||||
}
|
||||
|
||||
pub fn _getAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !?[]const u8 {
|
||||
return try parser.elementGetAttributeNS(self, ns, qname);
|
||||
}
|
||||
|
||||
pub fn _setAttribute(self: *parser.Element, qname: []const u8, value: []const u8) !void {
|
||||
return try parser.elementSetAttribute(self, qname, value);
|
||||
}
|
||||
|
||||
pub fn _setAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8, value: []const u8) !void {
|
||||
return try parser.elementSetAttributeNS(self, ns, qname, value);
|
||||
}
|
||||
|
||||
pub fn _removeAttribute(self: *parser.Element, qname: []const u8) !void {
|
||||
return try parser.elementRemoveAttribute(self, qname);
|
||||
}
|
||||
|
||||
pub fn _removeAttributeNS(self: *parser.Element, ns: []const u8, qname: []const u8) !void {
|
||||
return try parser.elementRemoveAttributeNS(self, ns, qname);
|
||||
}
|
||||
|
||||
pub fn _hasAttribute(self: *parser.Element, qname: []const u8) !bool {
|
||||
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: []u8, force: ?bool) !bool {
|
||||
_ = std.ascii.lowerString(qname, qname);
|
||||
const exists = try parser.elementHasAttribute(self, qname);
|
||||
|
||||
// If attribute is null, then:
|
||||
if (!exists) {
|
||||
// If force is not given or is true, create an attribute whose
|
||||
// local name is qualifiedName, value is the empty string and node
|
||||
// document is this’s node document, then append this attribute to
|
||||
// this, and then return true.
|
||||
if (force == null or force.?) {
|
||||
try parser.elementSetAttribute(self, qname, "");
|
||||
return true;
|
||||
}
|
||||
if (try parser.validateName(qname) == false) {
|
||||
return parser.DOMError.InvalidCharacter;
|
||||
}
|
||||
|
||||
// Return false.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise, if force is not given or is false, remove an attribute
|
||||
// given qualifiedName and this, and then return false.
|
||||
if (force == null or !force.?) {
|
||||
try parser.elementRemoveAttribute(self, qname);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Return true.
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn _getAttributeNode(self: *parser.Element, name: []const u8) !?*parser.Attribute {
|
||||
return try parser.elementGetAttributeNode(self, name);
|
||||
}
|
||||
|
||||
pub fn _getAttributeNodeNS(self: *parser.Element, ns: []const u8, name: []const u8) !?*parser.Attribute {
|
||||
return try parser.elementGetAttributeNodeNS(self, ns, name);
|
||||
}
|
||||
|
||||
pub fn _setAttributeNode(self: *parser.Element, attr: *parser.Attribute) !?*parser.Attribute {
|
||||
return try parser.elementSetAttributeNode(self, attr);
|
||||
}
|
||||
|
||||
pub fn _setAttributeNodeNS(self: *parser.Element, attr: *parser.Attribute) !?*parser.Attribute {
|
||||
return try parser.elementSetAttributeNodeNS(self, attr);
|
||||
}
|
||||
|
||||
pub fn _removeAttributeNode(self: *parser.Element, attr: *parser.Attribute) !*parser.Attribute {
|
||||
return try parser.elementRemoveAttributeNode(self, attr);
|
||||
}
|
||||
|
||||
pub fn _getElementsByTagName(
|
||||
self: *parser.Element,
|
||||
tag_name: []const u8,
|
||||
page: *Page,
|
||||
) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(
|
||||
page.arena,
|
||||
parser.elementToNode(self),
|
||||
tag_name,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn _getElementsByClassName(
|
||||
self: *parser.Element,
|
||||
classNames: []const u8,
|
||||
page: *Page,
|
||||
) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByClassName(
|
||||
page.arena,
|
||||
parser.elementToNode(self),
|
||||
classNames,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// ParentNode
|
||||
// https://dom.spec.whatwg.org/#parentnode
|
||||
pub fn get_children(self: *parser.Element) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionChildren(parser.elementToNode(self), false);
|
||||
}
|
||||
|
||||
pub fn get_firstElementChild(self: *parser.Element) !?Union {
|
||||
var children = try get_children(self);
|
||||
return try children._item(0);
|
||||
}
|
||||
|
||||
pub fn get_lastElementChild(self: *parser.Element) !?Union {
|
||||
// TODO we could check the last child node first, if it's an element,
|
||||
// we can return it directly instead of looping twice over the
|
||||
// children.
|
||||
var children = try get_children(self);
|
||||
const ln = try children.get_length();
|
||||
if (ln == 0) return null;
|
||||
return try children._item(ln - 1);
|
||||
}
|
||||
|
||||
pub fn get_childElementCount(self: *parser.Element) !u32 {
|
||||
var children = try get_children(self);
|
||||
return try children.get_length();
|
||||
}
|
||||
|
||||
// NonDocumentTypeChildNode
|
||||
// https://dom.spec.whatwg.org/#interface-nondocumenttypechildnode
|
||||
pub fn get_previousElementSibling(self: *parser.Element) !?Union {
|
||||
const res = try parser.nodePreviousElementSibling(parser.elementToNode(self));
|
||||
if (res == null) return null;
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
|
||||
}
|
||||
|
||||
pub fn get_nextElementSibling(self: *parser.Element) !?Union {
|
||||
const res = try parser.nodeNextElementSibling(parser.elementToNode(self));
|
||||
if (res == null) return null;
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
|
||||
}
|
||||
|
||||
fn getElementById(self: *parser.Element, id: []const u8) !?*parser.Node {
|
||||
// walk over the node tree fo find the node by id.
|
||||
const root = parser.elementToNode(self);
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = try walker.get_next(root, next) orelse return null;
|
||||
// ignore non-element nodes.
|
||||
if (try parser.nodeType(next.?) != .element) {
|
||||
continue;
|
||||
}
|
||||
const e = parser.nodeToElement(next.?);
|
||||
if (std.mem.eql(u8, id, try get_id(e))) return next;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _querySelector(self: *parser.Element, selector: []const u8, page: *Page) !?Union {
|
||||
if (selector.len == 0) return null;
|
||||
|
||||
const n = try css.querySelector(page.call_arena, parser.elementToNode(self), selector);
|
||||
|
||||
if (n == null) return null;
|
||||
|
||||
return try toInterface(parser.nodeToElement(n.?));
|
||||
}
|
||||
|
||||
pub fn _querySelectorAll(self: *parser.Element, selector: []const u8, page: *Page) !NodeList {
|
||||
return css.querySelectorAll(page.arena, parser.elementToNode(self), selector);
|
||||
}
|
||||
|
||||
pub fn _prepend(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.prepend(parser.elementToNode(self), nodes);
|
||||
}
|
||||
|
||||
pub fn _append(self: *parser.Element, nodes: []const Node.NodeOrText) !void {
|
||||
return Node.append(parser.elementToNode(self), nodes);
|
||||
}
|
||||
|
||||
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, page: *Page) !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.
|
||||
if (!try page.isNodeAttached(parser.elementToNode(self))) {
|
||||
return DOMRect{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
.width = 0,
|
||||
.height = 0,
|
||||
.bottom = 0,
|
||||
.right = 0,
|
||||
.top = 0,
|
||||
.left = 0,
|
||||
};
|
||||
}
|
||||
return page.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, page: *Page) ![]DOMRect {
|
||||
if (!try page.isNodeAttached(parser.elementToNode(self))) {
|
||||
return &.{};
|
||||
}
|
||||
const heap_ptr = try page.call_arena.create(DOMRect);
|
||||
heap_ptr.* = try page.renderer.getRect(self);
|
||||
return heap_ptr[0..1];
|
||||
}
|
||||
|
||||
pub fn get_clientWidth(_: *parser.Element, page: *Page) u32 {
|
||||
return page.renderer.width();
|
||||
}
|
||||
|
||||
pub fn get_clientHeight(_: *parser.Element, page: *Page) u32 {
|
||||
return page.renderer.height();
|
||||
}
|
||||
|
||||
pub fn _matches(self: *parser.Element, selectors: []const u8, page: *Page) !bool {
|
||||
const cssParse = @import("../css/css.zig").parse;
|
||||
const CssNodeWrap = @import("../css/libdom.zig").Node;
|
||||
const s = try cssParse(page.call_arena, selectors, .{});
|
||||
return s.match(CssNodeWrap{ .node = parser.elementToNode(self) });
|
||||
}
|
||||
|
||||
pub fn _scrollIntoViewIfNeeded(_: *parser.Element, center_if_needed: ?bool) void {
|
||||
_ = center_if_needed;
|
||||
}
|
||||
|
||||
const CheckVisibilityOpts = struct {
|
||||
contentVisibilityAuto: bool,
|
||||
opacityProperty: bool,
|
||||
visibilityProperty: bool,
|
||||
};
|
||||
|
||||
pub fn _checkVisibility(self: *parser.Element, opts: ?CheckVisibilityOpts) bool {
|
||||
_ = self;
|
||||
_ = opts;
|
||||
return true;
|
||||
}
|
||||
|
||||
const AttachShadowOpts = struct {
|
||||
mode: []const u8, // must be specified
|
||||
};
|
||||
pub fn _attachShadow(self: *parser.Element, opts: AttachShadowOpts, page: *Page) !*ShadowRoot {
|
||||
const mode = std.meta.stringToEnum(ShadowRoot.Mode, opts.mode) orelse return error.InvalidArgument;
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
if (state.shadow_root) |sr| {
|
||||
if (mode != sr.mode) {
|
||||
// this is the behavior per the spec
|
||||
return error.NotSupportedError;
|
||||
}
|
||||
|
||||
// TODO: the existing shadow root should be cleared!
|
||||
return sr;
|
||||
}
|
||||
|
||||
// Not sure what to do if there is no owner document
|
||||
const doc = try parser.nodeOwnerDocument(@ptrCast(self)) orelse return error.InvalidArgument;
|
||||
const fragment = try parser.documentCreateDocumentFragment(doc);
|
||||
const sr = try page.arena.create(ShadowRoot);
|
||||
sr.* = .{
|
||||
.host = self,
|
||||
.mode = mode,
|
||||
.proto = fragment,
|
||||
};
|
||||
state.shadow_root = sr;
|
||||
return sr;
|
||||
}
|
||||
|
||||
pub fn get_shadowRoot(self: *parser.Element, page: *Page) ?*ShadowRoot {
|
||||
const state = page.getNodeState(@alignCast(@ptrCast(self))) orelse return null;
|
||||
const sr = state.shadow_root orelse return null;
|
||||
if (sr.mode == .closed) {
|
||||
return null;
|
||||
}
|
||||
return sr;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Element" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let g = document.getElementById('content')", "undefined" },
|
||||
.{ "g.namespaceURI", "http://www.w3.org/1999/xhtml" },
|
||||
.{ "g.prefix", "null" },
|
||||
.{ "g.localName", "div" },
|
||||
.{ "g.tagName", "DIV" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let gs = document.getElementById('content')", "undefined" },
|
||||
.{ "gs.id", "content" },
|
||||
.{ "gs.id = 'foo'", "foo" },
|
||||
.{ "gs.id", "foo" },
|
||||
.{ "gs.id = 'content'", "content" },
|
||||
.{ "gs.className", "" },
|
||||
.{ "let gs2 = document.getElementById('para-empty')", "undefined" },
|
||||
.{ "gs2.className", "ok empty" },
|
||||
.{ "gs2.className = 'foo bar baz'", "foo bar baz" },
|
||||
.{ "gs2.className", "foo bar baz" },
|
||||
.{ "gs2.className = 'ok empty'", "ok empty" },
|
||||
.{ "let cl = gs2.classList", "undefined" },
|
||||
.{ "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" },
|
||||
.{ "a.attributes.length", "1" },
|
||||
.{ "a.getAttribute('id')", "content" },
|
||||
.{ "a.attributes['id'].value", "content" },
|
||||
.{
|
||||
\\ let x = '';
|
||||
\\ for (const attr of a.attributes) {
|
||||
\\ x += attr.name + '=' + attr.value;
|
||||
\\ }
|
||||
\\ x;
|
||||
,
|
||||
"id=content",
|
||||
},
|
||||
|
||||
.{ "a.hasAttribute('foo')", "false" },
|
||||
.{ "a.getAttribute('foo')", "null" },
|
||||
|
||||
.{ "a.setAttribute('foo', 'bar')", "undefined" },
|
||||
.{ "a.hasAttribute('foo')", "true" },
|
||||
.{ "a.getAttribute('foo')", "bar" },
|
||||
|
||||
.{ "a.setAttribute('foo', 'baz')", "undefined" },
|
||||
.{ "a.hasAttribute('foo')", "true" },
|
||||
.{ "a.getAttribute('foo')", "baz" },
|
||||
|
||||
.{ "a.removeAttribute('foo')", "undefined" },
|
||||
.{ "a.hasAttribute('foo')", "false" },
|
||||
.{ "a.getAttribute('foo')", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let b = document.getElementById('content')", "undefined" },
|
||||
.{ "b.toggleAttribute('foo')", "true" },
|
||||
.{ "b.hasAttribute('foo')", "true" },
|
||||
.{ "b.getAttribute('foo')", "" },
|
||||
|
||||
.{ "b.toggleAttribute('foo')", "false" },
|
||||
.{ "b.hasAttribute('foo')", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let c = document.getElementById('content')", "undefined" },
|
||||
.{ "c.children.length", "3" },
|
||||
.{ "c.firstElementChild.nodeName", "A" },
|
||||
.{ "c.lastElementChild.nodeName", "P" },
|
||||
.{ "c.childElementCount", "3" },
|
||||
|
||||
.{ "c.prepend(document.createTextNode('foo'))", "undefined" },
|
||||
.{ "c.append(document.createTextNode('bar'))", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let d = document.getElementById('para')", "undefined" },
|
||||
.{ "d.previousElementSibling.nodeName", "P" },
|
||||
.{ "d.nextElementSibling", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let e = document.getElementById('content')", "undefined" },
|
||||
.{ "e.querySelector('foo')", "null" },
|
||||
.{ "e.querySelector('#foo')", "null" },
|
||||
.{ "e.querySelector('#link').id", "link" },
|
||||
.{ "e.querySelector('#para').id", "para" },
|
||||
.{ "e.querySelector('*').id", "link" },
|
||||
.{ "e.querySelector('')", "null" },
|
||||
.{ "e.querySelector('*').id", "link" },
|
||||
.{ "e.querySelector('#content')", "null" },
|
||||
.{ "e.querySelector('#para').id", "para" },
|
||||
.{ "e.querySelector('.ok').id", "link" },
|
||||
.{ "e.querySelector('a ~ p').id", "para-empty" },
|
||||
|
||||
.{ "e.querySelectorAll('foo').length", "0" },
|
||||
.{ "e.querySelectorAll('#foo').length", "0" },
|
||||
.{ "e.querySelectorAll('#link').length", "1" },
|
||||
.{ "e.querySelectorAll('#link').item(0).id", "link" },
|
||||
.{ "e.querySelectorAll('#para').length", "1" },
|
||||
.{ "e.querySelectorAll('#para').item(0).id", "para" },
|
||||
.{ "e.querySelectorAll('*').length", "4" },
|
||||
.{ "e.querySelectorAll('p').length", "2" },
|
||||
.{ "e.querySelectorAll('.ok').item(0).id", "link" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let f = document.getElementById('content')", "undefined" },
|
||||
.{ "let ff = document.createAttribute('foo')", "undefined" },
|
||||
.{ "f.setAttributeNode(ff)", "null" },
|
||||
.{ "f.getAttributeNode('foo').name", "foo" },
|
||||
.{ "f.removeAttributeNode(ff).name", "foo" },
|
||||
.{ "f.getAttributeNode('bar')", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.getElementById('para').innerHTML", " And" },
|
||||
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
|
||||
|
||||
.{ "let h = document.getElementById('para-empty')", "undefined" },
|
||||
.{ "const prev = h.innerHTML", "undefined" },
|
||||
.{ "h.innerHTML = '<p id=\"hello\">hello world</p>'", "<p id=\"hello\">hello world</p>" },
|
||||
.{ "h.innerHTML", "<p id=\"hello\">hello world</p>" },
|
||||
.{ "h.firstChild.nodeName", "P" },
|
||||
.{ "h.firstChild.id", "hello" },
|
||||
.{ "h.firstChild.textContent", "hello world" },
|
||||
.{ "h.innerHTML = prev; true", "true" },
|
||||
.{ "document.getElementById('para-empty').innerHTML.trim()", "<span id=\"para-empty-child\"></span>" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.getElementById('para').outerHTML", "<p id=\"para\"> And</p>" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.getElementById('para').clientWidth", "1" },
|
||||
.{ "document.getElementById('para').clientHeight", "1" },
|
||||
|
||||
.{ "let r1 = document.getElementById('para').getBoundingClientRect()", "undefined" },
|
||||
.{ "r1.x", "0" },
|
||||
.{ "r1.y", "0" },
|
||||
.{ "r1.width", "1" },
|
||||
.{ "r1.height", "1" },
|
||||
|
||||
.{ "let r2 = document.getElementById('content').getBoundingClientRect()", "undefined" },
|
||||
.{ "r2.x", "1" },
|
||||
.{ "r2.y", "0" },
|
||||
.{ "r2.width", "1" },
|
||||
.{ "r2.height", "1" },
|
||||
|
||||
.{ "let r3 = document.getElementById('para').getBoundingClientRect()", "undefined" },
|
||||
.{ "r3.x", "0" },
|
||||
.{ "r3.y", "0" },
|
||||
.{ "r3.width", "1" },
|
||||
.{ "r3.height", "1" },
|
||||
|
||||
.{ "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(&.{
|
||||
.{ "const el = document.createElement('div');", "undefined" },
|
||||
.{ "el.id = 'matches'; el.className = 'ok';", "ok" },
|
||||
.{ "el.matches('#matches')", "true" },
|
||||
.{ "el.matches('.ok')", "true" },
|
||||
.{ "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>" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var div1 = document.createElement('div');", null },
|
||||
.{ "div1.innerHTML = \" <link/><table></table><a href='/a'>a</a><input type='checkbox'/>\"", null },
|
||||
.{ "div1.getElementsByTagName('a').length", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.createElement('a').hasAttributes()", "false" },
|
||||
.{ "var fc; (fc = document.createElement('div')).innerHTML = '<script><\\/script>'", null },
|
||||
.{ "fc.outerHTML", "<div><script></script></div>" },
|
||||
|
||||
.{ "fc; (fc = document.createElement('div')).innerHTML = '<script><\\/script><p>hello</p>'", null },
|
||||
.{ "fc.outerHTML", "<div><script></script><p>hello</p></div>" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const EventHandler = @import("../events/event.zig").EventHandler;
|
||||
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
const nod = @import("node.zig");
|
||||
|
||||
pub const Union = union(enum) {
|
||||
node: nod.Union,
|
||||
xhr: *@import("../xhr/xhr.zig").XMLHttpRequest,
|
||||
};
|
||||
|
||||
// EventTarget implementation
|
||||
pub const EventTarget = struct {
|
||||
pub const Self = parser.EventTarget;
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub fn toInterface(e: *parser.Event, et: *parser.EventTarget, page: *Page) !Union {
|
||||
// libdom assumes that all event targets are libdom nodes. They are not.
|
||||
|
||||
// The window is a common non-node target, but it's easy to handle as
|
||||
// its a singleton.
|
||||
if (@intFromPtr(et) == @intFromPtr(&page.window.base)) {
|
||||
return .{ .node = .{ .Window = &page.window } };
|
||||
}
|
||||
|
||||
// AbortSignal is another non-node target. It has a distinct usage though
|
||||
// so we hijack the event internal type to identity if.
|
||||
switch (try parser.eventGetInternalType(e)) {
|
||||
.abort_signal => {
|
||||
return .{ .node = .{ .AbortSignal = @fieldParentPtr("proto", @as(*parser.EventTargetTBase, @ptrCast(et))) } };
|
||||
},
|
||||
.xhr_event => {
|
||||
const XMLHttpRequestEventTarget = @import("../xhr/event_target.zig").XMLHttpRequestEventTarget;
|
||||
const base: *XMLHttpRequestEventTarget = @fieldParentPtr("base", @as(*parser.EventTargetTBase, @ptrCast(et)));
|
||||
return .{ .xhr = @fieldParentPtr("proto", base) };
|
||||
},
|
||||
else => {
|
||||
return .{ .node = try nod.Node.toInterface(@as(*parser.Node, @ptrCast(et))) };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
pub fn _addEventListener(
|
||||
self: *parser.EventTarget,
|
||||
typ: []const u8,
|
||||
listener: EventHandler.Listener,
|
||||
opts: ?EventHandler.Opts,
|
||||
page: *Page,
|
||||
) !void {
|
||||
_ = try EventHandler.register(page.arena, self, typ, listener, opts);
|
||||
}
|
||||
|
||||
const RemoveEventListenerOpts = union(enum) {
|
||||
opts: Opts,
|
||||
capture: bool,
|
||||
|
||||
const Opts = struct {
|
||||
capture: ?bool,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn _removeEventListener(
|
||||
self: *parser.EventTarget,
|
||||
typ: []const u8,
|
||||
listener: EventHandler.Listener,
|
||||
opts_: ?RemoveEventListenerOpts,
|
||||
) !void {
|
||||
var capture = false;
|
||||
if (opts_) |opts| {
|
||||
capture = switch (opts) {
|
||||
.capture => |c| c,
|
||||
.opts => |o| o.capture orelse false,
|
||||
};
|
||||
}
|
||||
|
||||
const cbk = (try listener.callback(self)) orelse return;
|
||||
|
||||
// check if event target has already this listener
|
||||
const lst = try parser.eventTargetHasListener(
|
||||
self,
|
||||
typ,
|
||||
capture,
|
||||
cbk.id,
|
||||
);
|
||||
if (lst == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove listener
|
||||
try parser.eventTargetRemoveEventListener(
|
||||
self,
|
||||
typ,
|
||||
lst.?,
|
||||
capture,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn _dispatchEvent(self: *parser.EventTarget, event: *parser.Event) !bool {
|
||||
return try parser.eventTargetDispatchEvent(self, event);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.EventTarget" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let content = document.getElementById('content')", "undefined" },
|
||||
.{ "let para = document.getElementById('para')", "undefined" },
|
||||
// NOTE: as some event properties will change during the event dispatching phases
|
||||
// we need to copy thoses values in order to check them afterwards
|
||||
.{
|
||||
\\ var nb = 0; var evt; var phase; var cur;
|
||||
\\ function cbk(event) {
|
||||
\\ evt = event;
|
||||
\\ phase = event.eventPhase;
|
||||
\\ cur = event.currentTarget;
|
||||
\\ nb ++;
|
||||
\\ }
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "content.addEventListener('basic', cbk)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt instanceof Event", "true" },
|
||||
.{ "evt.type", "basic" },
|
||||
.{ "phase", "2" },
|
||||
.{ "cur.getAttribute('id')", "content" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
|
||||
.{ "para.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "0" }, // handler is not called, no capture, not the target, no bubbling
|
||||
.{ "evt === undefined", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{ "content.addEventListener('basic', cbk)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{ "content.addEventListener('basic', cbk, true)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "2" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{ "content.removeEventListener('basic', cbk)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{ "content.removeEventListener('basic', cbk, {capture: true})", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('basic'))", "true" },
|
||||
.{ "nb", "0" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
|
||||
.{ "content.addEventListener('capture', cbk, true)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('capture'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt instanceof Event", "true" },
|
||||
.{ "evt.type", "capture" },
|
||||
.{ "phase", "2" },
|
||||
.{ "cur.getAttribute('id')", "content" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
|
||||
.{ "para.dispatchEvent(new Event('capture'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt instanceof Event", "true" },
|
||||
.{ "evt.type", "capture" },
|
||||
.{ "phase", "1" },
|
||||
.{ "cur.getAttribute('id')", "content" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
|
||||
.{ "content.addEventListener('bubbles', cbk)", "undefined" },
|
||||
.{ "content.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt instanceof Event", "true" },
|
||||
.{ "evt.type", "bubbles" },
|
||||
.{ "evt.bubbles", "true" },
|
||||
.{ "phase", "2" },
|
||||
.{ "cur.getAttribute('id')", "content" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; evt = undefined; phase = undefined; cur = undefined", "undefined" },
|
||||
.{ "para.dispatchEvent(new Event('bubbles', {bubbles: true}))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt instanceof Event", "true" },
|
||||
.{ "evt.type", "bubbles" },
|
||||
.{ "phase", "3" },
|
||||
.{ "cur.getAttribute('id')", "content" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const obj1 = {calls: 0, handleEvent: function() { this.calls += 1; } };", null },
|
||||
.{ "content.addEventListener('he', obj1);", null },
|
||||
.{ "content.dispatchEvent(new Event('he'));", null },
|
||||
.{ "obj1.calls", "1" },
|
||||
|
||||
.{ "content.removeEventListener('he', obj1);", null },
|
||||
.{ "content.dispatchEvent(new Event('he'));", null },
|
||||
.{ "obj1.calls", "1" },
|
||||
}, .{});
|
||||
|
||||
// doesn't crash on null receiver
|
||||
try runner.testCases(&.{
|
||||
.{ "content.addEventListener('he2', null);", null },
|
||||
.{ "content.dispatchEvent(new Event('he2'));", null },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#domimplementation
|
||||
pub const DOMImplementation = struct {
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub fn _createDocumentType(
|
||||
_: *DOMImplementation,
|
||||
qname: [:0]const u8,
|
||||
publicId: [:0]const u8,
|
||||
systemId: [:0]const u8,
|
||||
) !*parser.DocumentType {
|
||||
return try parser.domImplementationCreateDocumentType(qname, publicId, systemId);
|
||||
}
|
||||
|
||||
pub fn _createDocument(
|
||||
_: *DOMImplementation,
|
||||
namespace: ?[:0]const u8,
|
||||
qname: ?[:0]const u8,
|
||||
doctype: ?*parser.DocumentType,
|
||||
) !*parser.Document {
|
||||
return try parser.domImplementationCreateDocument(namespace, qname, doctype);
|
||||
}
|
||||
|
||||
pub fn _createHTMLDocument(_: *DOMImplementation, title: ?[]const u8) !*parser.DocumentHTML {
|
||||
return try parser.domImplementationCreateHTMLDocument(title);
|
||||
}
|
||||
|
||||
pub fn _hasFeature(_: *DOMImplementation) bool {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Implementation" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let impl = document.implementation", "undefined" },
|
||||
.{ "impl.createHTMLDocument();", "[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" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Element = @import("element.zig").Element;
|
||||
|
||||
pub const Interfaces = .{
|
||||
IntersectionObserver,
|
||||
IntersectionObserverEntry,
|
||||
};
|
||||
|
||||
// This is supposed to listen to change between the root and observation targets.
|
||||
// However, our rendered stores everything as 1 pixel sized boxes in a long row that never changes.
|
||||
// As such, there are no changes to intersections between the root and any target.
|
||||
// Instead we keep a list of all entries that are being observed.
|
||||
// The callback is called with all entries everytime a new entry is added(observed).
|
||||
// Potentially we should also call the callback at a regular interval.
|
||||
// The returned Entries are phony, they always indicate full intersection.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
|
||||
pub const IntersectionObserver = struct {
|
||||
page: *Page,
|
||||
callback: Env.Function,
|
||||
options: IntersectionObserverOptions,
|
||||
|
||||
observed_entries: std.ArrayListUnmanaged(IntersectionObserverEntry),
|
||||
|
||||
// new IntersectionObserver(callback)
|
||||
// new IntersectionObserver(callback, options) [not supported yet]
|
||||
pub fn constructor(callback: Env.Function, options_: ?IntersectionObserverOptions, page: *Page) !IntersectionObserver {
|
||||
var options = IntersectionObserverOptions{
|
||||
.root = parser.documentToNode(parser.documentHTMLToDocument(page.window.document)),
|
||||
.rootMargin = "0px 0px 0px 0px",
|
||||
.threshold = .{ .single = 0.0 },
|
||||
};
|
||||
if (options_) |*o| {
|
||||
if (o.root) |root| {
|
||||
options.root = root;
|
||||
} // Other properties are not used due to the way we render
|
||||
}
|
||||
|
||||
return .{
|
||||
.page = page,
|
||||
.callback = callback,
|
||||
.options = options,
|
||||
.observed_entries = .{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _disconnect(self: *IntersectionObserver) !void {
|
||||
self.observed_entries = .{}; // We don't free as it is on an arena
|
||||
}
|
||||
|
||||
pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element) !void {
|
||||
for (self.observed_entries.items) |*observer| {
|
||||
if (observer.target == target_element) {
|
||||
return; // Already observed
|
||||
}
|
||||
}
|
||||
|
||||
try self.observed_entries.append(self.page.arena, .{
|
||||
.page = self.page,
|
||||
.target = target_element,
|
||||
.options = &self.options,
|
||||
});
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "intersection observer",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void {
|
||||
for (self.observed_entries.items, 0..) |*observer, index| {
|
||||
if (observer.target == target) {
|
||||
_ = self.observed_entries.swapRemove(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _takeRecords(self: *IntersectionObserver) []IntersectionObserverEntry {
|
||||
return self.observed_entries.items;
|
||||
}
|
||||
};
|
||||
|
||||
const IntersectionObserverOptions = struct {
|
||||
root: ?*parser.Node, // Element or Document
|
||||
rootMargin: ?[]const u8,
|
||||
threshold: ?Threshold,
|
||||
|
||||
const Threshold = union(enum) {
|
||||
single: f32,
|
||||
list: []const f32,
|
||||
};
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry
|
||||
// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry
|
||||
pub const IntersectionObserverEntry = struct {
|
||||
page: *Page,
|
||||
target: *parser.Element,
|
||||
options: *IntersectionObserverOptions,
|
||||
|
||||
// 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 Element._getBoundingClientRect(self.target, self.page);
|
||||
}
|
||||
|
||||
// Returns the ratio of the intersectionRect to the boundingClientRect.
|
||||
pub fn get_intersectionRatio(_: *const IntersectionObserverEntry) f32 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Returns a DOMRectReadOnly representing the target's visible area.
|
||||
pub fn get_intersectionRect(self: *const IntersectionObserverEntry) !Element.DOMRect {
|
||||
return Element._getBoundingClientRect(self.target, self.page);
|
||||
}
|
||||
|
||||
// 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.
|
||||
pub fn get_isIntersecting(_: *const IntersectionObserverEntry) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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.page.window.document)) {
|
||||
return self.page.renderer.boundingRect();
|
||||
}
|
||||
|
||||
const root_type = try parser.nodeType(root);
|
||||
|
||||
var element: *parser.Element = undefined;
|
||||
switch (root_type) {
|
||||
.element => element = parser.nodeToElement(root),
|
||||
.document => {
|
||||
const doc = parser.nodeToDocument(root);
|
||||
element = (try parser.documentGetDocumentElement(doc)).?;
|
||||
},
|
||||
else => return error.InvalidState,
|
||||
}
|
||||
|
||||
return Element._getBoundingClientRect(element, self.page);
|
||||
}
|
||||
|
||||
// The Element whose intersection with the root changed.
|
||||
pub fn get_target(self: *const IntersectionObserverEntry) *parser.Element {
|
||||
return self.target;
|
||||
}
|
||||
|
||||
// TODO: pub fn get_time(self: *const IntersectionObserverEntry)
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.IntersectionObserver" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "new IntersectionObserver(() => {}).observe(document.documentElement);", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let count_a = 0;", "undefined" },
|
||||
.{ "const a1 = document.createElement('div');", "undefined" },
|
||||
.{ "new IntersectionObserver(entries => {count_a += 1;}).observe(a1);", "undefined" },
|
||||
.{ "count_a;", "1" },
|
||||
}, .{});
|
||||
|
||||
// This test is documenting current behavior, not correct behavior.
|
||||
// Currently every time observe is called, the callback is called with all entries.
|
||||
try runner.testCases(&.{
|
||||
.{ "let count_b = 0;", "undefined" },
|
||||
.{ "let observer_b = new IntersectionObserver(entries => {count_b = entries.length;});", "undefined" },
|
||||
.{ "const b1 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_b.observe(b1);", "undefined" },
|
||||
.{ "count_b;", "1" },
|
||||
.{ "const b2 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_b.observe(b2);", "undefined" },
|
||||
.{ "count_b;", "2" },
|
||||
}, .{});
|
||||
|
||||
// Re-observing is a no-op
|
||||
try runner.testCases(&.{
|
||||
.{ "let count_bb = 0;", "undefined" },
|
||||
.{ "let observer_bb = new IntersectionObserver(entries => {count_bb = entries.length;});", "undefined" },
|
||||
.{ "const bb1 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_bb.observe(bb1);", "undefined" },
|
||||
.{ "count_bb;", "1" },
|
||||
.{ "observer_bb.observe(bb1);", "undefined" },
|
||||
.{ "count_bb;", "1" }, // Still 1, not 2
|
||||
}, .{});
|
||||
|
||||
// Unobserve
|
||||
try runner.testCases(&.{
|
||||
.{ "let count_c = 0;", "undefined" },
|
||||
.{ "let observer_c = new IntersectionObserver(entries => { count_c = entries.length;});", "undefined" },
|
||||
.{ "const c1 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_c.observe(c1);", "undefined" },
|
||||
.{ "count_c;", "1" },
|
||||
.{ "observer_c.unobserve(c1);", "undefined" },
|
||||
.{ "const c2 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_c.observe(c2);", "undefined" },
|
||||
.{ "count_c;", "1" },
|
||||
}, .{});
|
||||
|
||||
// Disconnect
|
||||
try runner.testCases(&.{
|
||||
.{ "let observer_d = new IntersectionObserver(entries => {});", "undefined" },
|
||||
.{ "let d1 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_d.observe(d1);", "undefined" },
|
||||
.{ "observer_d.disconnect();", "undefined" },
|
||||
.{ "observer_d.takeRecords().length;", "0" },
|
||||
}, .{});
|
||||
|
||||
// takeRecords
|
||||
try runner.testCases(&.{
|
||||
.{ "let observer_e = new IntersectionObserver(entries => {});", "undefined" },
|
||||
.{ "let e1 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_e.observe(e1);", "undefined" },
|
||||
.{ "const e2 = document.createElement('div');", "undefined" },
|
||||
.{ "observer_e.observe(e2);", "undefined" },
|
||||
.{ "observer_e.takeRecords().length;", "2" },
|
||||
}, .{});
|
||||
|
||||
// Entry
|
||||
try runner.testCases(&.{
|
||||
.{ "let entry;", "undefined" },
|
||||
.{ "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;", "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;", "1" },
|
||||
.{ "entry.rootBounds.height;", "1" },
|
||||
.{ "entry.target;", "[object HTMLDivElement]" },
|
||||
}, .{});
|
||||
|
||||
// Options
|
||||
try runner.testCases(&.{
|
||||
.{ "const new_root = document.createElement('span');", null },
|
||||
.{ "document.body.appendChild(new_root);", null },
|
||||
.{ "let new_entry;", "undefined" },
|
||||
.{
|
||||
\\ const new_observer = new IntersectionObserver(
|
||||
\\ entries => { new_entry = entries[0]; },
|
||||
\\ {root: new_root, rootMargin: '0px 0px 0px 0px', threshold: [0]});
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
.{ "new_observer.observe(document.createElement('div'));", "undefined" },
|
||||
.{ "new_entry.rootBounds.x;", "1" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
|
||||
pub const Interfaces = .{
|
||||
MutationObserver,
|
||||
MutationRecord,
|
||||
};
|
||||
|
||||
const Walker = @import("../dom/walker.zig").WalkerChildren;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
|
||||
pub const MutationObserver = struct {
|
||||
loop: *Loop,
|
||||
cbk: Env.Function,
|
||||
arena: Allocator,
|
||||
connected: bool,
|
||||
scheduled: bool,
|
||||
loop_node: Loop.CallbackNode,
|
||||
|
||||
// List of records which were observed. When the call scope ends, we need to
|
||||
// execute our callback with it.
|
||||
observed: std.ArrayListUnmanaged(MutationRecord),
|
||||
|
||||
pub fn constructor(cbk: Env.Function, page: *Page) !MutationObserver {
|
||||
return .{
|
||||
.cbk = cbk,
|
||||
.loop = page.loop,
|
||||
.observed = .{},
|
||||
.connected = true,
|
||||
.scheduled = false,
|
||||
.arena = page.arena,
|
||||
.loop_node = .{ .func = callback },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?Options) !void {
|
||||
const arena = self.arena;
|
||||
var options = options_ orelse Options{};
|
||||
if (options.attributeFilter.len > 0) {
|
||||
options.attributeFilter = try arena.dupe([]const u8, options.attributeFilter);
|
||||
}
|
||||
|
||||
const observer = try arena.create(Observer);
|
||||
observer.* = .{
|
||||
.node = node,
|
||||
.options = options,
|
||||
.mutation_observer = self,
|
||||
.event_node = .{ .id = self.cbk.id, .func = Observer.handle },
|
||||
};
|
||||
|
||||
// register node's events
|
||||
if (options.childList or options.subtree) {
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Node, node),
|
||||
"DOMNodeInserted",
|
||||
&observer.event_node,
|
||||
false,
|
||||
);
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Node, node),
|
||||
"DOMNodeRemoved",
|
||||
&observer.event_node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (options.attr()) {
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Node, node),
|
||||
"DOMAttrModified",
|
||||
&observer.event_node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (options.cdata()) {
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Node, node),
|
||||
"DOMCharacterDataModified",
|
||||
&observer.event_node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (options.subtree) {
|
||||
_ = try parser.eventTargetAddEventListener(
|
||||
parser.toEventTarget(parser.Node, node),
|
||||
"DOMSubtreeModified",
|
||||
&observer.event_node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn callback(node: *Loop.CallbackNode, _: *?u63) void {
|
||||
const self: *MutationObserver = @fieldParentPtr("loop_node", node);
|
||||
if (self.connected == false) {
|
||||
self.scheduled = true;
|
||||
return;
|
||||
}
|
||||
self.scheduled = false;
|
||||
|
||||
const records = self.observed.items;
|
||||
if (records.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
defer self.observed.clearRetainingCapacity();
|
||||
|
||||
var result: Env.Function.Result = undefined;
|
||||
self.cbk.tryCall(void, .{records}, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "mutation observer",
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _disconnect(self: *MutationObserver) !void {
|
||||
self.connected = false;
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _takeRecords(_: *const MutationObserver) ?[]const u8 {
|
||||
return &[_]u8{};
|
||||
}
|
||||
};
|
||||
|
||||
pub const MutationRecord = struct {
|
||||
type: []const u8,
|
||||
target: *parser.Node,
|
||||
added_nodes: NodeList = .{},
|
||||
removed_nodes: NodeList = .{},
|
||||
previous_sibling: ?*parser.Node = null,
|
||||
next_sibling: ?*parser.Node = null,
|
||||
attribute_name: ?[]const u8 = null,
|
||||
attribute_namespace: ?[]const u8 = null,
|
||||
old_value: ?[]const u8 = null,
|
||||
|
||||
pub fn get_type(self: *const MutationRecord) []const u8 {
|
||||
return self.type;
|
||||
}
|
||||
|
||||
pub fn get_addedNodes(self: *MutationRecord) *NodeList {
|
||||
return &self.added_nodes;
|
||||
}
|
||||
|
||||
pub fn get_removedNodes(self: *MutationRecord) *NodeList {
|
||||
return &self.removed_nodes;
|
||||
}
|
||||
|
||||
pub fn get_target(self: *const MutationRecord) *parser.Node {
|
||||
return self.target;
|
||||
}
|
||||
|
||||
pub fn get_attributeName(self: *const MutationRecord) ?[]const u8 {
|
||||
return self.attribute_name;
|
||||
}
|
||||
|
||||
pub fn get_attributeNamespace(self: *const MutationRecord) ?[]const u8 {
|
||||
return self.attribute_namespace;
|
||||
}
|
||||
|
||||
pub fn get_previousSibling(self: *const MutationRecord) ?*parser.Node {
|
||||
return self.previous_sibling;
|
||||
}
|
||||
|
||||
pub fn get_nextSibling(self: *const MutationRecord) ?*parser.Node {
|
||||
return self.next_sibling;
|
||||
}
|
||||
|
||||
pub fn get_oldValue(self: *const MutationRecord) ?[]const u8 {
|
||||
return self.old_value;
|
||||
}
|
||||
};
|
||||
|
||||
const Options = struct {
|
||||
childList: bool = false,
|
||||
attributes: bool = false,
|
||||
characterData: bool = false,
|
||||
subtree: bool = false,
|
||||
attributeOldValue: bool = false,
|
||||
characterDataOldValue: bool = false,
|
||||
attributeFilter: [][]const u8 = &.{},
|
||||
|
||||
fn attr(self: Options) bool {
|
||||
return self.attributes or self.attributeOldValue or self.attributeFilter.len > 0;
|
||||
}
|
||||
|
||||
fn cdata(self: Options) bool {
|
||||
return self.characterData or self.characterDataOldValue;
|
||||
}
|
||||
};
|
||||
|
||||
const Observer = struct {
|
||||
node: *parser.Node,
|
||||
options: Options,
|
||||
|
||||
// reference back to the MutationObserver so that we can access the arena
|
||||
// and batch the mutation records.
|
||||
mutation_observer: *MutationObserver,
|
||||
|
||||
event_node: parser.EventNode,
|
||||
|
||||
fn appliesTo(
|
||||
self: *const Observer,
|
||||
target: *parser.Node,
|
||||
event_type: MutationEventType,
|
||||
event: *parser.MutationEvent,
|
||||
) !bool {
|
||||
if (event_type == .DOMAttrModified and self.options.attributeFilter.len > 0) {
|
||||
const attribute_name = try parser.mutationEventAttributeName(event);
|
||||
for (self.options.attributeFilter) |needle| blk: {
|
||||
if (std.mem.eql(u8, attribute_name, needle)) {
|
||||
break :blk;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// mutation on any target is always ok.
|
||||
if (self.options.subtree) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if target equals node, alway ok.
|
||||
if (target == self.node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no subtree, no same target and no childlist, always noky.
|
||||
if (!self.options.childList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// target must be a child of o.node
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = walker.get_next(self.node, next) catch break orelse break;
|
||||
if (next.? == target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn handle(en: *parser.EventNode, event: *parser.Event) void {
|
||||
const self: *Observer = @fieldParentPtr("event_node", en);
|
||||
self._handle(event) catch |err| {
|
||||
log.err(.web_api, "handle error", .{ .err = err, .source = "mutation observer" });
|
||||
};
|
||||
}
|
||||
|
||||
fn _handle(self: *Observer, event: *parser.Event) !void {
|
||||
var mutation_observer = self.mutation_observer;
|
||||
|
||||
const node = blk: {
|
||||
const event_target = try parser.eventTarget(event) orelse return;
|
||||
break :blk parser.eventTargetToNode(event_target);
|
||||
};
|
||||
|
||||
const mutation_event = parser.eventToMutationEvent(event);
|
||||
const event_type = blk: {
|
||||
const t = try parser.eventType(event);
|
||||
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
|
||||
};
|
||||
|
||||
if (try self.appliesTo(node, event_type, mutation_event) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
var record = MutationRecord{
|
||||
.target = self.node,
|
||||
.type = event_type.recordType(),
|
||||
};
|
||||
|
||||
const arena = mutation_observer.arena;
|
||||
switch (event_type) {
|
||||
.DOMAttrModified => {
|
||||
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
|
||||
if (self.options.attributeOldValue) {
|
||||
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
|
||||
}
|
||||
},
|
||||
.DOMCharacterDataModified => {
|
||||
if (self.options.characterDataOldValue) {
|
||||
record.old_value = parser.mutationEventPrevValue(mutation_event) catch null;
|
||||
}
|
||||
},
|
||||
.DOMNodeInserted => {
|
||||
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
|
||||
try record.added_nodes.append(arena, related_node);
|
||||
}
|
||||
},
|
||||
.DOMNodeRemoved => {
|
||||
if (parser.mutationEventRelatedNode(mutation_event) catch null) |related_node| {
|
||||
try record.removed_nodes.append(arena, related_node);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
try mutation_observer.observed.append(arena, record);
|
||||
|
||||
if (mutation_observer.scheduled == false) {
|
||||
mutation_observer.scheduled = true;
|
||||
_ = try mutation_observer.loop.timeout(0, &mutation_observer.loop_node);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MutationEventType = enum {
|
||||
DOMAttrModified,
|
||||
DOMCharacterDataModified,
|
||||
DOMNodeInserted,
|
||||
DOMNodeRemoved,
|
||||
|
||||
fn recordType(self: MutationEventType) []const u8 {
|
||||
return switch (self) {
|
||||
.DOMAttrModified => "attributes",
|
||||
.DOMCharacterDataModified => "characterData",
|
||||
.DOMNodeInserted => "childList",
|
||||
.DOMNodeRemoved => "childList",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.MutationObserver" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "new MutationObserver(() => {}).observe(document, { childList: true });", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var nb = 0;
|
||||
\\ var mrs;
|
||||
\\ new MutationObserver((mu) => {
|
||||
\\ mrs = mu;
|
||||
\\ nb++;
|
||||
\\ }).observe(document.firstElementChild, { attributes: true, attributeOldValue: true });
|
||||
\\ document.firstElementChild.setAttribute("foo", "bar");
|
||||
\\ // ignored b/c it's about another target.
|
||||
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "nb", "1" },
|
||||
.{ "mrs[0].type", "attributes" },
|
||||
.{ "mrs[0].target == document.firstElementChild", "true" },
|
||||
.{ "mrs[0].target.getAttribute('foo')", "bar" },
|
||||
.{ "mrs[0].attributeName", "foo" },
|
||||
.{ "mrs[0].oldValue", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var node = document.getElementById("para").firstChild;
|
||||
\\ var nb2 = 0;
|
||||
\\ var mrs2;
|
||||
\\ new MutationObserver((mu) => {
|
||||
\\ mrs2 = mu;
|
||||
\\ nb2++;
|
||||
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
|
||||
\\ node.data = "foo";
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "nb2", "1" },
|
||||
.{ "mrs2[0].type", "characterData" },
|
||||
.{ "mrs2[0].target == node", "true" },
|
||||
.{ "mrs2[0].target.data", "foo" },
|
||||
.{ "mrs2[0].oldValue", " And" },
|
||||
}, .{});
|
||||
|
||||
// tests that mutation observers that have a callback which trigger the
|
||||
// mutation observer don't crash.
|
||||
// https://github.com/lightpanda-io/browser/issues/550
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var node = document.getElementById("para");
|
||||
\\ new MutationObserver(() => {
|
||||
\\ node.innerText = 'a';
|
||||
\\ }).observe(document, { subtree:true,childList:true });
|
||||
\\ node.innerText = "2";
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "node.innerText", "a" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var node = document.getElementById("para");
|
||||
\\ var attrWatch = 0;
|
||||
\\ new MutationObserver(() => {
|
||||
\\ attrWatch++;
|
||||
\\ }).observe(document, { attributeFilter: ["name"], subtree: true });
|
||||
\\ node.setAttribute("id", "1");
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "attrWatch", "0" },
|
||||
.{ "node.setAttribute('name', 'other');", null },
|
||||
.{ "attrWatch", "1" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,722 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const generate = @import("../../runtime/generate.zig");
|
||||
|
||||
const Page = @import("../page.zig").Page;
|
||||
const EventTarget = @import("event_target.zig").EventTarget;
|
||||
|
||||
// DOM
|
||||
const Attr = @import("attribute.zig").Attr;
|
||||
const CData = @import("character_data.zig");
|
||||
const Element = @import("element.zig").Element;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
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;
|
||||
|
||||
// HTML
|
||||
const HTML = @import("../html/html.zig");
|
||||
const HTMLElem = @import("../html/elements.zig");
|
||||
|
||||
// Node interfaces
|
||||
pub const Interfaces = .{
|
||||
Attr,
|
||||
CData.CharacterData,
|
||||
CData.Interfaces,
|
||||
Element,
|
||||
Document,
|
||||
DocumentType,
|
||||
DocumentFragment,
|
||||
HTMLCollection,
|
||||
HTMLAllCollection,
|
||||
HTMLCollectionIterator,
|
||||
HTML.Interfaces,
|
||||
};
|
||||
|
||||
pub const Union = generate.Union(Interfaces);
|
||||
|
||||
// Node implementation
|
||||
pub const Node = struct {
|
||||
pub const Self = parser.Node;
|
||||
pub const prototype = *EventTarget;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn toInterface(node: *parser.Node) !Union {
|
||||
return switch (try parser.nodeType(node)) {
|
||||
.element => try HTMLElem.toInterface(
|
||||
Union,
|
||||
@as(*parser.Element, @ptrCast(node)),
|
||||
),
|
||||
.comment => .{ .Comment = @as(*parser.Comment, @ptrCast(node)) },
|
||||
.text => .{ .Text = @as(*parser.Text, @ptrCast(node)) },
|
||||
.cdata_section => .{ .CDATASection = @as(*parser.CDATASection, @ptrCast(node)) },
|
||||
.processing_instruction => .{ .ProcessingInstruction = @as(*parser.ProcessingInstruction, @ptrCast(node)) },
|
||||
.document => .{ .HTMLDocument = @as(*parser.DocumentHTML, @ptrCast(node)) },
|
||||
.document_type => .{ .DocumentType = @as(*parser.DocumentType, @ptrCast(node)) },
|
||||
.attribute => .{ .Attr = @as(*parser.Attribute, @ptrCast(node)) },
|
||||
.document_fragment => .{ .DocumentFragment = @as(*parser.DocumentFragment, @ptrCast(node)) },
|
||||
else => @panic("node type not handled"), // TODO
|
||||
};
|
||||
}
|
||||
|
||||
// class attributes
|
||||
|
||||
pub const _ELEMENT_NODE = @intFromEnum(parser.NodeType.element);
|
||||
pub const _ATTRIBUTE_NODE = @intFromEnum(parser.NodeType.attribute);
|
||||
pub const _TEXT_NODE = @intFromEnum(parser.NodeType.text);
|
||||
pub const _CDATA_SECTION_NODE = @intFromEnum(parser.NodeType.cdata_section);
|
||||
pub const _PROCESSING_INSTRUCTION_NODE = @intFromEnum(parser.NodeType.processing_instruction);
|
||||
pub const _COMMENT_NODE = @intFromEnum(parser.NodeType.comment);
|
||||
pub const _DOCUMENT_NODE = @intFromEnum(parser.NodeType.document);
|
||||
pub const _DOCUMENT_TYPE_NODE = @intFromEnum(parser.NodeType.document_type);
|
||||
pub const _DOCUMENT_FRAGMENT_NODE = @intFromEnum(parser.NodeType.document_fragment);
|
||||
|
||||
// These 3 are deprecated, but both Chrome and Firefox still expose them
|
||||
pub const _ENTITY_REFERENCE_NODE = @intFromEnum(parser.NodeType.entity_reference);
|
||||
pub const _ENTITY_NODE = @intFromEnum(parser.NodeType.entity);
|
||||
pub const _NOTATION_NODE = @intFromEnum(parser.NodeType.notation);
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
// Read-only attributes
|
||||
|
||||
pub fn get_firstChild(self: *parser.Node) !?Union {
|
||||
const res = try parser.nodeFirstChild(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try Node.toInterface(res.?);
|
||||
}
|
||||
|
||||
pub fn get_lastChild(self: *parser.Node) !?Union {
|
||||
const res = try parser.nodeLastChild(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try Node.toInterface(res.?);
|
||||
}
|
||||
|
||||
pub fn get_nextSibling(self: *parser.Node) !?Union {
|
||||
const res = try parser.nodeNextSibling(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try Node.toInterface(res.?);
|
||||
}
|
||||
|
||||
pub fn get_previousSibling(self: *parser.Node) !?Union {
|
||||
const res = try parser.nodePreviousSibling(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try Node.toInterface(res.?);
|
||||
}
|
||||
|
||||
pub fn get_parentNode(self: *parser.Node) !?Union {
|
||||
const res = try parser.nodeParentNode(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try Node.toInterface(res.?);
|
||||
}
|
||||
|
||||
pub fn get_parentElement(self: *parser.Node) !?HTMLElem.Union {
|
||||
const res = try parser.nodeParentElement(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, @as(*parser.Element, @ptrCast(res.?)));
|
||||
}
|
||||
|
||||
pub fn get_nodeName(self: *parser.Node) ![]const u8 {
|
||||
return try parser.nodeName(self);
|
||||
}
|
||||
|
||||
pub fn get_nodeType(self: *parser.Node) !u8 {
|
||||
return @intFromEnum(try parser.nodeType(self));
|
||||
}
|
||||
|
||||
pub fn get_ownerDocument(self: *parser.Node) !?*parser.DocumentHTML {
|
||||
const res = try parser.nodeOwnerDocument(self);
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return @as(*parser.DocumentHTML, @ptrCast(res.?));
|
||||
}
|
||||
|
||||
pub fn get_isConnected(self: *parser.Node) !bool {
|
||||
// TODO: handle Shadow DOM
|
||||
if (try parser.nodeType(self) == .document) {
|
||||
return true;
|
||||
}
|
||||
return try Node.get_parentNode(self) != null;
|
||||
}
|
||||
|
||||
// Read/Write attributes
|
||||
|
||||
pub fn get_nodeValue(self: *parser.Node) !?[]const u8 {
|
||||
return try parser.nodeValue(self);
|
||||
}
|
||||
|
||||
pub fn set_nodeValue(self: *parser.Node, data: []u8) !void {
|
||||
try parser.nodeSetValue(self, data);
|
||||
}
|
||||
|
||||
pub fn get_textContent(self: *parser.Node) !?[]const u8 {
|
||||
return try parser.nodeTextContent(self);
|
||||
}
|
||||
|
||||
pub fn set_textContent(self: *parser.Node, data: []u8) !void {
|
||||
return try parser.nodeSetTextContent(self, data);
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
pub fn _appendChild(self: *parser.Node, child: *parser.Node) !Union {
|
||||
// TODO: DocumentFragment special case
|
||||
const res = try parser.nodeAppendChild(self, child);
|
||||
return try Node.toInterface(res);
|
||||
}
|
||||
|
||||
pub fn _cloneNode(self: *parser.Node, deep: ?bool) !Union {
|
||||
const clone = try parser.nodeCloneNode(self, deep orelse false);
|
||||
return try Node.toInterface(clone);
|
||||
}
|
||||
|
||||
pub fn _compareDocumentPosition(self: *parser.Node, other: *parser.Node) !u32 {
|
||||
if (self == other) return 0;
|
||||
|
||||
const docself = try parser.nodeOwnerDocument(self);
|
||||
const docother = try parser.nodeOwnerDocument(other);
|
||||
|
||||
// Both are in different document.
|
||||
if (docself == null or docother == null or docother.? != docself.?) {
|
||||
return @intFromEnum(parser.DocumentPosition.disconnected);
|
||||
}
|
||||
|
||||
// TODO Both are in a different trees in the same document.
|
||||
|
||||
const w = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
|
||||
// Is other a descendant of self?
|
||||
while (true) {
|
||||
next = try w.get_next(self, next) orelse break;
|
||||
if (other == next) {
|
||||
return @intFromEnum(parser.DocumentPosition.following) +
|
||||
@intFromEnum(parser.DocumentPosition.contained_by);
|
||||
}
|
||||
}
|
||||
|
||||
// Is self a descendant of other?
|
||||
next = null;
|
||||
while (true) {
|
||||
next = try w.get_next(other, next) orelse break;
|
||||
if (self == next) {
|
||||
return @intFromEnum(parser.DocumentPosition.contains) +
|
||||
@intFromEnum(parser.DocumentPosition.preceding);
|
||||
}
|
||||
}
|
||||
|
||||
next = null;
|
||||
while (true) {
|
||||
next = try w.get_next(parser.documentToNode(docself.?), next) orelse break;
|
||||
if (other == next) {
|
||||
// other precedes self.
|
||||
return @intFromEnum(parser.DocumentPosition.preceding);
|
||||
}
|
||||
if (self == next) {
|
||||
// other follows self.
|
||||
return @intFromEnum(parser.DocumentPosition.following);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
pub fn _contains(self: *parser.Node, other: *parser.Node) !bool {
|
||||
return try parser.nodeContains(self, other);
|
||||
}
|
||||
|
||||
// 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(.web_api, "not implemented", .{ .feature = "getRootNode composed" });
|
||||
};
|
||||
return try Node.toInterface(try parser.nodeGetRootNode(self));
|
||||
}
|
||||
|
||||
pub fn _hasChildNodes(self: *parser.Node) !bool {
|
||||
return try parser.nodeHasChildNodes(self);
|
||||
}
|
||||
|
||||
pub fn get_childNodes(self: *parser.Node, page: *Page) !NodeList {
|
||||
const allocator = page.arena;
|
||||
var list: NodeList = .{};
|
||||
|
||||
var n = try parser.nodeFirstChild(self) orelse return list;
|
||||
while (true) {
|
||||
try list.append(allocator, n);
|
||||
n = try parser.nodeNextSibling(n) orelse return list;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _insertBefore(self: *parser.Node, new_node: *parser.Node, ref_node_: ?*parser.Node) !Union {
|
||||
if (ref_node_) |ref_node| {
|
||||
return Node.toInterface(try parser.nodeInsertBefore(self, new_node, ref_node));
|
||||
}
|
||||
return _appendChild(self, new_node);
|
||||
}
|
||||
|
||||
pub fn _isDefaultNamespace(self: *parser.Node, namespace: ?[]const u8) !bool {
|
||||
return try parser.nodeIsDefaultNamespace(self, namespace);
|
||||
}
|
||||
|
||||
pub fn _isEqualNode(self: *parser.Node, other: *parser.Node) !bool {
|
||||
// TODO: other is not an optional parameter, but can be null.
|
||||
return try parser.nodeIsEqualNode(self, other);
|
||||
}
|
||||
|
||||
pub fn _isSameNode(self: *parser.Node, other: *parser.Node) !bool {
|
||||
// TODO: other is not an optional parameter, but can be null.
|
||||
// NOTE: there is no need to use isSameNode(); instead use the === strict equality operator
|
||||
return try parser.nodeIsSameNode(self, other);
|
||||
}
|
||||
|
||||
pub fn _lookupPrefix(self: *parser.Node, namespace: ?[]const u8) !?[]const u8 {
|
||||
// TODO: other is not an optional parameter, but can be null.
|
||||
if (namespace == null) {
|
||||
return null;
|
||||
}
|
||||
if (std.mem.eql(u8, namespace.?, "")) {
|
||||
return null;
|
||||
}
|
||||
return try parser.nodeLookupPrefix(self, namespace.?);
|
||||
}
|
||||
|
||||
pub fn _lookupNamespaceURI(self: *parser.Node, prefix: ?[]const u8) !?[]const u8 {
|
||||
// TODO: other is not an optional parameter, but can be null.
|
||||
return try parser.nodeLookupNamespaceURI(self, prefix);
|
||||
}
|
||||
|
||||
pub fn _normalize(self: *parser.Node) !void {
|
||||
return try parser.nodeNormalize(self);
|
||||
}
|
||||
|
||||
pub fn _removeChild(self: *parser.Node, child: *parser.Node) !Union {
|
||||
const res = try parser.nodeRemoveChild(self, child);
|
||||
return try Node.toInterface(res);
|
||||
}
|
||||
|
||||
pub fn _replaceChild(self: *parser.Node, new_child: *parser.Node, old_child: *parser.Node) !Union {
|
||||
const res = try parser.nodeReplaceChild(self, new_child, old_child);
|
||||
return try Node.toInterface(res);
|
||||
}
|
||||
|
||||
// Check if the hierarchy node tree constraints are respected.
|
||||
// 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 NodeOrText) bool {
|
||||
for (nodes) |n| {
|
||||
if (n.is(self)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn prepend(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
if (!hierarchy(self, nodes)) {
|
||||
return parser.DOMError.HierarchyRequest;
|
||||
}
|
||||
|
||||
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
|
||||
|
||||
if (try parser.nodeFirstChild(self)) |first| {
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeInsertBefore(self, try node.toNode(doc), first);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
if (!hierarchy(self, nodes)) {
|
||||
return parser.DOMError.HierarchyRequest;
|
||||
}
|
||||
|
||||
const doc = (try parser.nodeOwnerDocument(self)) orelse return;
|
||||
for (nodes) |node| {
|
||||
_ = try parser.nodeAppendChild(self, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replaceChildren(self: *parser.Node, nodes: []const NodeOrText) !void {
|
||||
if (nodes.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check hierarchy
|
||||
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, try node.toNode(doc));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn removeChildren(self: *parser.Node) !void {
|
||||
if (!try parser.nodeHasChildNodes(self)) return;
|
||||
|
||||
const children = try parser.nodeGetChildNodes(self);
|
||||
const ln = try parser.nodeListLength(children);
|
||||
var i: u32 = 0;
|
||||
while (i < ln) {
|
||||
defer i += 1;
|
||||
// we always retrieve the 0 index child on purpose: libdom nodelist
|
||||
// are dynamic. So the next child to remove is always as pos 0.
|
||||
const child = try parser.nodeListItem(children, 0) orelse continue;
|
||||
_ = try parser.nodeRemoveChild(self, child);
|
||||
}
|
||||
}
|
||||
|
||||
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| @alignCast(@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");
|
||||
test "Browser.DOM.node" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
{
|
||||
var err_out: ?[]const u8 = null;
|
||||
try runner.exec(
|
||||
\\ function trimAndReplace(str) {
|
||||
\\ str = str.replace(/(\r\n|\n|\r)/gm,'');
|
||||
\\ str = str.replace(/\s+/g, ' ');
|
||||
\\ str = str.trim();
|
||||
\\ return str;
|
||||
\\ }
|
||||
, "trimAndReplace", &err_out);
|
||||
}
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.body.compareDocumentPosition(document.firstChild); ", "10" },
|
||||
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"content\"));", "10" },
|
||||
.{ "document.getElementById(\"content\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "20" },
|
||||
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"link\"));", "0" },
|
||||
.{ "document.getElementById(\"para-empty\").compareDocumentPosition(document.getElementById(\"link\"));", "2" },
|
||||
.{ "document.getElementById(\"link\").compareDocumentPosition(document.getElementById(\"para-empty\"));", "4" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.getElementById('content').getRootNode().__proto__.constructor.name", "HTMLDocument" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
// for next test cases
|
||||
.{ "let content = document.getElementById('content')", "undefined" },
|
||||
.{ "let link = document.getElementById('link')", "undefined" },
|
||||
.{ "let first_child = content.firstChild.nextSibling", "undefined" }, // nextSibling because of line return \n
|
||||
|
||||
.{ "let body_first_child = document.body.firstChild", "undefined" },
|
||||
.{ "body_first_child.localName", "div" },
|
||||
.{ "body_first_child.__proto__.constructor.name", "HTMLDivElement" },
|
||||
.{ "document.getElementById('para-empty').firstChild.firstChild", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let last_child = content.lastChild.previousSibling", "undefined" }, // previousSibling because of line return \n
|
||||
.{ "last_child.__proto__.constructor.name", "Comment" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let next_sibling = link.nextSibling.nextSibling", "undefined" },
|
||||
.{ "next_sibling.localName", "p" },
|
||||
.{ "next_sibling.__proto__.constructor.name", "HTMLParagraphElement" },
|
||||
.{ "content.nextSibling.nextSibling", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let prev_sibling = document.getElementById('para-empty').previousSibling.previousSibling", "undefined" },
|
||||
.{ "prev_sibling.localName", "a" },
|
||||
.{ "prev_sibling.__proto__.constructor.name", "HTMLAnchorElement" },
|
||||
.{ "content.previousSibling", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let parent = document.getElementById('para').parentElement", "undefined" },
|
||||
.{ "parent.localName", "div" },
|
||||
.{ "parent.__proto__.constructor.name", "HTMLDivElement" },
|
||||
.{ "let h = content.parentElement.parentElement", "undefined" },
|
||||
.{ "h.parentElement", "null" },
|
||||
.{ "h.parentNode.__proto__.constructor.name", "HTMLDocument" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "first_child.nodeName === 'A'", "true" },
|
||||
.{ "link.firstChild.nodeName === '#text'", "true" },
|
||||
.{ "last_child.nodeName === '#comment'", "true" },
|
||||
.{ "document.nodeName === '#document'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "first_child.nodeType === 1", "true" },
|
||||
.{ "link.firstChild.nodeType === 3", "true" },
|
||||
.{ "last_child.nodeType === 8", "true" },
|
||||
.{ "document.nodeType === 9", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let owner = content.ownerDocument", "undefined" },
|
||||
.{ "owner.__proto__.constructor.name", "HTMLDocument" },
|
||||
.{ "document.ownerDocument", "null" },
|
||||
.{ "let owner2 = document.createElement('div').ownerDocument", "undefined" },
|
||||
.{ "owner2.__proto__.constructor.name", "HTMLDocument" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "content.isConnected", "true" },
|
||||
.{ "document.isConnected", "true" },
|
||||
.{ "document.createElement('div').isConnected", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "last_child.nodeValue === 'comment'", "true" },
|
||||
.{ "link.nodeValue === null", "true" },
|
||||
.{ "let text = link.firstChild", "undefined" },
|
||||
.{ "text.nodeValue === 'OK'", "true" },
|
||||
.{ "text.nodeValue = 'OK modified'", "OK modified" },
|
||||
.{ "text.nodeValue === 'OK modified'", "true" },
|
||||
.{ "link.nodeValue = 'nothing'", "nothing" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "text.textContent === 'OK modified'", "true" },
|
||||
.{ "trimAndReplace(content.textContent) === 'OK modified And'", "true" },
|
||||
.{ "text.textContent = 'OK'", "OK" },
|
||||
.{ "text.textContent", "OK" },
|
||||
.{ "trimAndReplace(document.getElementById('para-empty').textContent)", "" },
|
||||
.{ "document.getElementById('para-empty').textContent = 'OK'", "OK" },
|
||||
.{ "document.getElementById('para-empty').firstChild.nodeName === '#text'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let append = document.createElement('h1')", "undefined" },
|
||||
.{ "content.appendChild(append).toString()", "[object HTMLHeadingElement]" },
|
||||
.{ "content.lastChild.__proto__.constructor.name", "HTMLHeadingElement" },
|
||||
.{ "content.appendChild(link).toString()", "[object HTMLAnchorElement]" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let clone = link.cloneNode()", "undefined" },
|
||||
.{ "clone.toString()", "[object HTMLAnchorElement]" },
|
||||
.{ "clone.parentNode === null", "true" },
|
||||
.{ "clone.firstChild === null", "true" },
|
||||
.{ "let clone_deep = link.cloneNode(true)", "undefined" },
|
||||
.{ "clone_deep.firstChild.nodeName === '#text'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "link.contains(text)", "true" },
|
||||
.{ "text.contains(link)", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "link.hasChildNodes()", "true" },
|
||||
.{ "text.hasChildNodes()", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "link.childNodes.length", "1" },
|
||||
.{ "text.childNodes.length", "0" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let insertBefore = document.createElement('a')", "undefined" },
|
||||
.{ "link.insertBefore(insertBefore, text) !== undefined", "true" },
|
||||
.{ "link.firstChild.localName === 'a'", "true" },
|
||||
|
||||
.{ "let insertBefore2 = document.createElement('b')", null },
|
||||
.{ "link.insertBefore(insertBefore2, null).localName", "b" },
|
||||
.{ "link.childNodes[link.childNodes.length - 1].localName", "b" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
// TODO: does not seems to work
|
||||
// .{ "link.isDefaultNamespace('')", "true" },
|
||||
.{ "link.isDefaultNamespace('false')", "false" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let equal1 = document.createElement('a')", "undefined" },
|
||||
.{ "let equal2 = document.createElement('a')", "undefined" },
|
||||
.{ "equal1.textContent = 'is equal'", "is equal" },
|
||||
.{ "equal2.textContent = 'is equal'", "is equal" },
|
||||
// TODO: does not seems to work
|
||||
// .{ "equal1.isEqualNode(equal2)", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.body.isSameNode(document.body)", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
// TODO: no test
|
||||
.{ "link.normalize()", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "content.removeChild(append) !== undefined", "true" },
|
||||
.{ "last_child.__proto__.constructor.name !== 'HTMLHeadingElement'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let replace = document.createElement('div')", "undefined" },
|
||||
.{ "link.replaceChild(replace, insertBefore) !== undefined", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "Node.ELEMENT_NODE", "1" },
|
||||
.{ "Node.ATTRIBUTE_NODE", "2" },
|
||||
.{ "Node.TEXT_NODE", "3" },
|
||||
.{ "Node.CDATA_SECTION_NODE", "4" },
|
||||
.{ "Node.PROCESSING_INSTRUCTION_NODE", "7" },
|
||||
.{ "Node.COMMENT_NODE", "8" },
|
||||
.{ "Node.DOCUMENT_NODE", "9" },
|
||||
.{ "Node.DOCUMENT_TYPE_NODE", "10" },
|
||||
.{ "Node.DOCUMENT_FRAGMENT_NODE", "11" },
|
||||
.{ "Node.ENTITY_REFERENCE_NODE", "5" },
|
||||
.{ "Node.ENTITY_NODE", "6" },
|
||||
.{ "Node.NOTATION_NODE", "12" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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;
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Performance,
|
||||
PerformanceEntry,
|
||||
PerformanceMark,
|
||||
};
|
||||
|
||||
const MarkOptions = struct {
|
||||
detail: ?Env.JsObject = null,
|
||||
start_time: ?f64 = null,
|
||||
};
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
pub fn _mark(_: *Performance, name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
|
||||
const mark: PerformanceMark = try .constructor(name, _options, page);
|
||||
// TODO: Should store this in an entries list
|
||||
return mark;
|
||||
}
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceEntry
|
||||
pub const PerformanceEntry = struct {
|
||||
const PerformanceEntryType = enum {
|
||||
element,
|
||||
event,
|
||||
first_input,
|
||||
largest_contentful_paint,
|
||||
layout_shift,
|
||||
long_animation_frame,
|
||||
longtask,
|
||||
mark,
|
||||
measure,
|
||||
navigation,
|
||||
paint,
|
||||
resource,
|
||||
taskattribution,
|
||||
visibility_state,
|
||||
|
||||
pub fn toString(self: PerformanceEntryType) []const u8 {
|
||||
return switch (self) {
|
||||
.first_input => "first-input",
|
||||
.largest_contentful_paint => "largest-contentful-paint",
|
||||
.layout_shift => "layout-shift",
|
||||
.long_animation_frame => "long-animation-frame",
|
||||
.visibility_state => "visibility-state",
|
||||
else => @tagName(self),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
duration: f64 = 0.0,
|
||||
entry_type: PerformanceEntryType,
|
||||
name: []const u8,
|
||||
start_time: f64 = 0.0,
|
||||
|
||||
pub fn get_duration(self: *const PerformanceEntry) f64 {
|
||||
return self.duration;
|
||||
}
|
||||
|
||||
pub fn get_entryType(self: *const PerformanceEntry) PerformanceEntryType {
|
||||
return self.entry_type;
|
||||
}
|
||||
|
||||
pub fn get_name(self: *const PerformanceEntry) []const u8 {
|
||||
return self.name;
|
||||
}
|
||||
|
||||
pub fn get_startTime(self: *const PerformanceEntry) f64 {
|
||||
return self.start_time;
|
||||
}
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceMark
|
||||
pub const PerformanceMark = struct {
|
||||
pub const prototype = *PerformanceEntry;
|
||||
|
||||
proto: PerformanceEntry,
|
||||
detail: ?Env.JsObject,
|
||||
|
||||
pub fn constructor(name: []const u8, _options: ?MarkOptions, page: *Page) !PerformanceMark {
|
||||
const perf = &page.window.performance;
|
||||
|
||||
const options = _options orelse MarkOptions{};
|
||||
const start_time = options.start_time orelse perf._now();
|
||||
const detail = if (options.detail) |d| try d.persist() else null;
|
||||
|
||||
if (start_time < 0.0) {
|
||||
return error.TypeError;
|
||||
}
|
||||
|
||||
const duped_name = try page.arena.dupe(u8, name);
|
||||
const proto = PerformanceEntry{ .name = duped_name, .entry_type = .mark, .start_time = start_time };
|
||||
|
||||
return .{ .proto = proto, .detail = detail };
|
||||
}
|
||||
|
||||
pub fn get_detail(self: *const PerformanceMark) ?Env.JsObject {
|
||||
return self.detail;
|
||||
}
|
||||
};
|
||||
|
||||
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.2);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
test "Browser.Performance.Mark" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let performance = window.performance", "undefined" },
|
||||
.{ "performance instanceof Performance", "true" },
|
||||
.{ "let mark = performance.mark(\"start\")", "undefined" },
|
||||
.{ "mark instanceof PerformanceMark", "true" },
|
||||
.{ "mark.name", "start" },
|
||||
.{ "mark.entryType", "mark" },
|
||||
.{ "mark.duration", "0" },
|
||||
.{ "mark.detail", "null" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
|
||||
const PerformanceEntry = @import("performance.zig").PerformanceEntry;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver
|
||||
pub const PerformanceObserver = struct {
|
||||
pub const _supportedEntryTypes = [0][]const u8{};
|
||||
|
||||
pub fn constructor(cbk: Env.Function) PerformanceObserver {
|
||||
_ = cbk;
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *const PerformanceObserver, options_: ?Options) void {
|
||||
_ = self;
|
||||
_ = options_;
|
||||
return;
|
||||
}
|
||||
|
||||
pub fn _disconnect(self: *PerformanceObserver) void {
|
||||
_ = self;
|
||||
}
|
||||
|
||||
pub fn _takeRecords(_: *const PerformanceObserver) []PerformanceEntry {
|
||||
return &[_]PerformanceEntry{};
|
||||
}
|
||||
};
|
||||
|
||||
const Options = struct {
|
||||
buffered: ?bool = null,
|
||||
durationThreshold: ?f64 = null,
|
||||
entryTypes: ?[]const []const u8 = null,
|
||||
type: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.PerformanceObserver" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "PerformanceObserver.supportedEntryTypes.length", "0" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// 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 Node = @import("node.zig").Node;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
// https://dom.spec.whatwg.org/#processinginstruction
|
||||
pub const ProcessingInstruction = struct {
|
||||
pub const Self = parser.ProcessingInstruction;
|
||||
|
||||
// TODO for libdom processing instruction inherit from node.
|
||||
// But the spec says it must inherit from CDATA.
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn get_target(self: *parser.ProcessingInstruction) ![]const u8 {
|
||||
// libdom stores the ProcessingInstruction target in the node's name.
|
||||
return try parser.nodeName(parser.processingInstructionToNode(self));
|
||||
}
|
||||
|
||||
// There's something wrong when we try to clone a ProcessInstruction normally.
|
||||
// The resulting object can't be cast back into a node (it crashes). This is
|
||||
// a simple workaround.
|
||||
pub fn _cloneNode(self: *parser.ProcessingInstruction, _: ?bool, page: *Page) !*parser.ProcessingInstruction {
|
||||
return try parser.documentCreateProcessingInstruction(
|
||||
@ptrCast(page.window.document),
|
||||
try get_target(self),
|
||||
(try get_data(self)) orelse "",
|
||||
);
|
||||
}
|
||||
|
||||
pub fn get_data(self: *parser.ProcessingInstruction) !?[]const u8 {
|
||||
return try parser.nodeValue(parser.processingInstructionToNode(self));
|
||||
}
|
||||
|
||||
pub fn set_data(self: *parser.ProcessingInstruction, data: []u8) !void {
|
||||
try parser.nodeSetValue(parser.processingInstructionToNode(self), data);
|
||||
}
|
||||
|
||||
// netsurf's ProcessInstruction doesn't implement the dom_node_get_attributes
|
||||
// and thus will crash if we try to call nodeIsEqualNode.
|
||||
pub fn _isEqualNode(self: *parser.ProcessingInstruction, other_node: *parser.Node) !bool {
|
||||
if (try parser.nodeType(other_node) != .processing_instruction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other: *parser.ProcessingInstruction = @ptrCast(other_node);
|
||||
|
||||
if (std.mem.eql(u8, try get_target(self), try get_target(other)) == false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
const self_data = try get_data(self);
|
||||
const other_data = try get_data(other);
|
||||
if (self_data == null and other_data != null) {
|
||||
return false;
|
||||
}
|
||||
if (self_data != null and other_data == null) {
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, self_data.?, other_data.?) == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.ProcessingInstruction" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let pi = document.createProcessingInstruction('foo', 'bar')", "undefined" },
|
||||
.{ "pi.target", "foo" },
|
||||
.{ "pi.data", "bar" },
|
||||
.{ "pi.data = 'foo'", "foo" },
|
||||
.{ "pi.data", "foo" },
|
||||
|
||||
.{ "let pi2 = pi.cloneNode()", "undefined" },
|
||||
.{ "pi2.nodeType", "7" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let pi11 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
|
||||
.{ "let pi12 = document.createProcessingInstruction('target2', 'data2');", "undefined" },
|
||||
.{ "let pi13 = document.createProcessingInstruction('target1', 'data1');", "undefined" },
|
||||
.{ "pi11.isEqualNode(pi11)", "true" },
|
||||
.{ "pi11.isEqualNode(pi13)", "true" },
|
||||
.{ "pi11.isEqualNode(pi12)", "false" },
|
||||
.{ "pi12.isEqualNode(pi13)", "false" },
|
||||
.{ "pi11.isEqualNode(document)", "false" },
|
||||
.{ "document.isEqualNode(pi11)", "false" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
const NodeUnion = @import("node.zig").Union;
|
||||
const Node = @import("node.zig").Node;
|
||||
|
||||
pub const Interfaces = .{
|
||||
AbstractRange,
|
||||
Range,
|
||||
};
|
||||
|
||||
pub const AbstractRange = struct {
|
||||
collapsed: bool,
|
||||
end_container: *parser.Node,
|
||||
end_offset: i32,
|
||||
start_container: *parser.Node,
|
||||
start_offset: i32,
|
||||
|
||||
pub fn updateCollapsed(self: *AbstractRange) void {
|
||||
// TODO: Eventually, compare properly.
|
||||
self.collapsed = false;
|
||||
}
|
||||
|
||||
pub fn get_collapsed(self: *const AbstractRange) bool {
|
||||
return self.collapsed;
|
||||
}
|
||||
|
||||
pub fn get_endContainer(self: *const AbstractRange) !NodeUnion {
|
||||
return Node.toInterface(self.end_container);
|
||||
}
|
||||
|
||||
pub fn get_endOffset(self: *const AbstractRange) i32 {
|
||||
return self.end_offset;
|
||||
}
|
||||
|
||||
pub fn get_startContainer(self: *const AbstractRange) !NodeUnion {
|
||||
return Node.toInterface(self.start_container);
|
||||
}
|
||||
|
||||
pub fn get_startOffset(self: *const AbstractRange) i32 {
|
||||
return self.start_offset;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Range = struct {
|
||||
pub const prototype = *AbstractRange;
|
||||
|
||||
proto: AbstractRange,
|
||||
|
||||
// The Range() constructor returns a newly created Range object whose start
|
||||
// and end is the global Document object.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Range/Range
|
||||
pub fn constructor(page: *Page) Range {
|
||||
const proto: AbstractRange = .{
|
||||
.collapsed = true,
|
||||
.end_container = parser.documentHTMLToNode(page.window.document),
|
||||
.end_offset = 0,
|
||||
.start_container = parser.documentHTMLToNode(page.window.document),
|
||||
.start_offset = 0,
|
||||
};
|
||||
|
||||
return .{ .proto = proto };
|
||||
}
|
||||
|
||||
pub fn _setStart(self: *Range, node: *parser.Node, offset: i32) void {
|
||||
self.proto.start_container = node;
|
||||
self.proto.start_offset = offset;
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
pub fn _setEnd(self: *Range, node: *parser.Node, offset: i32) void {
|
||||
self.proto.end_container = node;
|
||||
self.proto.end_offset = offset;
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
pub fn _createContextualFragment(_: *Range, fragment: []const u8, page: *Page) !*parser.DocumentFragment {
|
||||
const document_html = page.window.document;
|
||||
const document = parser.documentHTMLToDocument(document_html);
|
||||
const doc_frag = try parser.documentParseFragmentFromStr(document, fragment);
|
||||
return doc_frag;
|
||||
}
|
||||
|
||||
pub fn _selectNodeContents(self: *Range, node: *parser.Node) !void {
|
||||
self.proto.start_container = node;
|
||||
self.proto.start_offset = 0;
|
||||
self.proto.end_container = node;
|
||||
|
||||
// Set end_offset
|
||||
switch (try parser.nodeType(node)) {
|
||||
.text, .cdata_section, .comment, .processing_instruction => {
|
||||
// For text-like nodes, end_offset should be the length of the text data
|
||||
if (try parser.nodeValue(node)) |text_data| {
|
||||
self.proto.end_offset = @intCast(text_data.len);
|
||||
} else {
|
||||
self.proto.end_offset = 0;
|
||||
}
|
||||
},
|
||||
else => {
|
||||
// For element and other nodes, end_offset is the number of children
|
||||
const child_nodes = try parser.nodeGetChildNodes(node);
|
||||
const child_count = try parser.nodeListLength(child_nodes);
|
||||
self.proto.end_offset = @intCast(child_count);
|
||||
},
|
||||
}
|
||||
|
||||
self.proto.updateCollapsed();
|
||||
}
|
||||
|
||||
// The Range.detach() method does nothing. It used to disable the Range
|
||||
// object and enable the browser to release associated resources. The
|
||||
// method has been kept for compatibility.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Range/detach
|
||||
pub fn _detach(_: *Range) void {}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Range" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
// Test Range constructor
|
||||
.{ "let range = new Range()", "undefined" },
|
||||
.{ "range instanceof Range", "true" },
|
||||
.{ "range instanceof AbstractRange", "true" },
|
||||
|
||||
// Test initial state - collapsed range
|
||||
.{ "range.collapsed", "true" },
|
||||
.{ "range.startOffset", "0" },
|
||||
.{ "range.endOffset", "0" },
|
||||
.{ "range.startContainer instanceof HTMLDocument", "true" },
|
||||
.{ "range.endContainer instanceof HTMLDocument", "true" },
|
||||
|
||||
// Test document.createRange()
|
||||
.{ "let docRange = document.createRange()", "undefined" },
|
||||
.{ "docRange instanceof Range", "true" },
|
||||
.{ "docRange.collapsed", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const container = document.getElementById('content');", null },
|
||||
|
||||
// Test text range
|
||||
.{ "const commentNode = container.childNodes[7];", null },
|
||||
.{ "commentNode.nodeValue", "comment" },
|
||||
.{ "const textRange = document.createRange();", null },
|
||||
.{ "textRange.selectNodeContents(commentNode)", "undefined" },
|
||||
.{ "textRange.startOffset", "0" },
|
||||
.{ "textRange.endOffset", "7" }, // length of `comment`
|
||||
|
||||
// Test Node range
|
||||
.{ "const nodeRange = document.createRange();", null },
|
||||
.{ "nodeRange.selectNodeContents(container)", "undefined" },
|
||||
.{ "nodeRange.startOffset", "0" },
|
||||
.{ "nodeRange.endOffset", "9" }, // length of container.childNodes
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
ResizeObserver,
|
||||
};
|
||||
|
||||
// WEB IDL https://drafts.csswg.org/resize-observer/#resize-observer-interface
|
||||
pub const ResizeObserver = struct {
|
||||
pub fn constructor(cbk: Env.Function) ResizeObserver {
|
||||
_ = cbk;
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *const ResizeObserver, element: *parser.Element, options_: ?Options) void {
|
||||
_ = self;
|
||||
_ = element;
|
||||
_ = options_;
|
||||
return;
|
||||
}
|
||||
|
||||
pub fn _unobserve(self: *const ResizeObserver, element: *parser.Element) void {
|
||||
_ = self;
|
||||
_ = element;
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _disconnect(self: *ResizeObserver) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
|
||||
const Options = struct {
|
||||
box: []const u8,
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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 Element = @import("element.zig").Element;
|
||||
const ElementUnion = @import("element.zig").Union;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#interface-shadowroot
|
||||
pub const ShadowRoot = struct {
|
||||
pub const prototype = *parser.DocumentFragment;
|
||||
pub const subtype = .node;
|
||||
|
||||
mode: Mode,
|
||||
host: *parser.Element,
|
||||
proto: *parser.DocumentFragment,
|
||||
|
||||
pub const Mode = enum {
|
||||
open,
|
||||
closed,
|
||||
};
|
||||
|
||||
pub fn get_host(self: *const ShadowRoot) !ElementUnion {
|
||||
return Element.toInterface(self.host);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.ShadowRoot" {
|
||||
defer testing.reset();
|
||||
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const div1 = document.createElement('div');", null },
|
||||
.{ "let sr1 = div1.attachShadow({mode: 'open'})", null },
|
||||
.{ "sr1.host == div1", "true" },
|
||||
.{ "div1.attachShadow({mode: 'open'}) == sr1", "true" },
|
||||
.{ "div1.shadowRoot == sr1", "true" },
|
||||
|
||||
.{ "try { div1.attachShadow({mode: 'closed'}) } catch (e) { e }", "Error: NotSupportedError" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const div2 = document.createElement('di2');", null },
|
||||
.{ "let sr2 = div2.attachShadow({mode: 'closed'})", null },
|
||||
.{ "sr2.host == div2", "true" },
|
||||
.{ "div2.shadowRoot", "null" }, // null when attached with 'closed'
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const iterator = @import("../iterator/iterator.zig");
|
||||
|
||||
const Function = @import("../env.zig").Function;
|
||||
const JsObject = @import("../env.zig").JsObject;
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
|
||||
pub const Interfaces = .{
|
||||
DOMTokenList,
|
||||
DOMTokenListIterable,
|
||||
TokenListEntriesIterator,
|
||||
TokenListEntriesIterator.Iterable,
|
||||
};
|
||||
|
||||
// https://dom.spec.whatwg.org/#domtokenlist
|
||||
pub const DOMTokenList = struct {
|
||||
pub const Self = parser.TokenList;
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub fn get_length(self: *parser.TokenList) !u32 {
|
||||
return parser.tokenListGetLength(self);
|
||||
}
|
||||
|
||||
pub fn _item(self: *parser.TokenList, index: u32) !?[]const u8 {
|
||||
return parser.tokenListItem(self, index);
|
||||
}
|
||||
|
||||
pub fn _contains(self: *parser.TokenList, token: []const u8) !bool {
|
||||
return parser.tokenListContains(self, token);
|
||||
}
|
||||
|
||||
pub fn _add(self: *parser.TokenList, tokens: []const []const u8) !void {
|
||||
for (tokens) |token| {
|
||||
try parser.tokenListAdd(self, token);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _remove(self: *parser.TokenList, tokens: []const []const u8) !void {
|
||||
for (tokens) |token| {
|
||||
try parser.tokenListRemove(self, token);
|
||||
}
|
||||
}
|
||||
|
||||
/// If token is the empty string, then throw a "SyntaxError" DOMException.
|
||||
/// If token contains any ASCII whitespace, then throw an
|
||||
/// "InvalidCharacterError" DOMException.
|
||||
fn validateToken(token: []const u8) !void {
|
||||
if (token.len == 0) {
|
||||
return parser.DOMError.Syntax;
|
||||
}
|
||||
for (token) |c| {
|
||||
if (std.ascii.isWhitespace(c)) return parser.DOMError.InvalidCharacter;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _toggle(self: *parser.TokenList, token: []const u8, force: ?bool) !bool {
|
||||
try validateToken(token);
|
||||
const exists = try parser.tokenListContains(self, token);
|
||||
if (exists) {
|
||||
if (force == null or force.? == false) {
|
||||
try parser.tokenListRemove(self, token);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (force == null or force.? == true) {
|
||||
try parser.tokenListAdd(self, token);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn _replace(self: *parser.TokenList, token: []const u8, new: []const u8) !bool {
|
||||
try validateToken(token);
|
||||
try validateToken(new);
|
||||
const exists = try parser.tokenListContains(self, token);
|
||||
if (!exists) return false;
|
||||
try parser.tokenListRemove(self, token);
|
||||
try parser.tokenListAdd(self, new);
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO to implement.
|
||||
pub fn _supports(_: *parser.TokenList, token: []const u8) !bool {
|
||||
try validateToken(token);
|
||||
return error.TypeError;
|
||||
}
|
||||
|
||||
pub fn get_value(self: *parser.TokenList) !?[]const u8 {
|
||||
return (try parser.tokenListGetValue(self)) orelse "";
|
||||
}
|
||||
|
||||
pub fn set_value(self: *parser.TokenList, value: []const u8) !void {
|
||||
return parser.tokenListSetValue(self, value);
|
||||
}
|
||||
|
||||
pub fn _toString(self: *parser.TokenList) ![]const u8 {
|
||||
return (try get_value(self)) orelse "";
|
||||
}
|
||||
|
||||
pub fn _keys(self: *parser.TokenList) !iterator.U32Iterator {
|
||||
return .{ .length = try get_length(self) };
|
||||
}
|
||||
|
||||
pub fn _values(self: *parser.TokenList) DOMTokenListIterable {
|
||||
return DOMTokenListIterable.init(.{ .token_list = self });
|
||||
}
|
||||
|
||||
pub fn _entries(self: *parser.TokenList) TokenListEntriesIterator {
|
||||
return TokenListEntriesIterator.init(.{ .token_list = self });
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *parser.TokenList) DOMTokenListIterable {
|
||||
return _values(self);
|
||||
}
|
||||
|
||||
// TODO handle thisArg
|
||||
pub fn _forEach(self: *parser.TokenList, cbk: Function, this_arg: JsObject) !void {
|
||||
var entries = _entries(self);
|
||||
while (try entries._next()) |entry| {
|
||||
var result: Function.Result = undefined;
|
||||
cbk.tryCallWithThis(void, this_arg, .{ entry.@"1", entry.@"0", self }, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.soure = "tokenList foreach",
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DOMTokenListIterable = iterator.Iterable(Iterator, "DOMTokenListIterable");
|
||||
const TokenListEntriesIterator = iterator.NumericEntries(Iterator, "TokenListEntriesIterator");
|
||||
|
||||
pub const Iterator = struct {
|
||||
index: u32 = 0,
|
||||
token_list: *parser.TokenList,
|
||||
|
||||
// used when wrapped in an iterator.NumericEntries
|
||||
pub const Error = parser.DOMError;
|
||||
|
||||
pub fn _next(self: *Iterator) !?[]const u8 {
|
||||
const index = self.index;
|
||||
self.index = index + 1;
|
||||
return DOMTokenList._item(self.token_list, index);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.TokenList" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let gs = document.getElementById('para-empty')", "undefined" },
|
||||
.{ "let cl = gs.classList", "undefined" },
|
||||
.{ "gs.className", "ok empty" },
|
||||
.{ "cl.value", "ok empty" },
|
||||
.{ "cl.length", "2" },
|
||||
.{ "gs.className = 'foo bar baz'", "foo bar baz" },
|
||||
.{ "gs.className", "foo bar baz" },
|
||||
.{ "cl.length", "3" },
|
||||
.{ "gs.className = 'ok empty'", "ok empty" },
|
||||
.{ "cl.length", "2" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let cl2 = gs.classList", "undefined" },
|
||||
.{ "cl2.length", "2" },
|
||||
.{ "cl2.item(0)", "ok" },
|
||||
.{ "cl2.item(1)", "empty" },
|
||||
.{ "cl2.contains('ok')", "true" },
|
||||
.{ "cl2.contains('nok')", "false" },
|
||||
.{ "cl2.add('foo', 'bar', 'baz')", "undefined" },
|
||||
.{ "cl2.length", "5" },
|
||||
.{ "cl2.remove('foo', 'bar', 'baz')", "undefined" },
|
||||
.{ "cl2.length", "2" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let cl3 = gs.classList", "undefined" },
|
||||
.{ "cl3.toggle('ok')", "false" },
|
||||
.{ "cl3.toggle('ok')", "true" },
|
||||
.{ "cl3.length", "2" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let cl4 = gs.classList", "undefined" },
|
||||
.{ "cl4.replace('ok', 'nok')", "true" },
|
||||
.{ "cl4.value", "empty nok" },
|
||||
.{ "cl4.replace('nok', 'ok')", "true" },
|
||||
.{ "cl4.value", "empty ok" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let cl5 = gs.classList", "undefined" },
|
||||
.{ "let keys = [...cl5.keys()]", "undefined" },
|
||||
.{ "keys.length", "2" },
|
||||
.{ "keys[0]", "0" },
|
||||
.{ "keys[1]", "1" },
|
||||
|
||||
.{ "let values = [...cl5.values()]", "undefined" },
|
||||
.{ "values.length", "2" },
|
||||
.{ "values[0]", "empty" },
|
||||
.{ "values[1]", "ok" },
|
||||
|
||||
.{ "let entries = [...cl5.entries()]", "undefined" },
|
||||
.{ "entries.length", "2" },
|
||||
.{ "entries[0]", "0,empty" },
|
||||
.{ "entries[1]", "1,ok" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let cl6 = gs.classList", "undefined" },
|
||||
.{ "cl6.value = 'a b ccc'", "a b ccc" },
|
||||
.{ "cl6.value", "a b ccc" },
|
||||
.{ "cl6.toString()", "a b ccc" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
@@ -17,124 +17,90 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const File = std.fs.File;
|
||||
|
||||
const parser = @import("netsurf.zig");
|
||||
const Walker = @import("dom/walker.zig").WalkerChildren;
|
||||
const parser = @import("netsurf");
|
||||
const Walker = @import("../dom/walker.zig").WalkerChildren;
|
||||
|
||||
// writer must be a std.io.Writer
|
||||
pub fn writeHTML(doc: *parser.Document, writer: anytype) !void {
|
||||
try writer.writeAll("<!DOCTYPE html>\n");
|
||||
try writeChildren(parser.documentToNode(doc), writer);
|
||||
try writeNode(parser.documentToNode(doc), writer);
|
||||
try writer.writeAll("\n");
|
||||
}
|
||||
|
||||
// Spec: https://www.w3.org/TR/xml/#sec-prolog-dtd
|
||||
pub fn writeDocType(doc_type: *parser.DocumentType, writer: anytype) !void {
|
||||
try writer.writeAll("<!DOCTYPE ");
|
||||
try writer.writeAll(try parser.documentTypeGetName(doc_type));
|
||||
|
||||
const public_id = try parser.documentTypeGetPublicId(doc_type);
|
||||
const system_id = try parser.documentTypeGetSystemId(doc_type);
|
||||
if (public_id.len != 0 and system_id.len != 0) {
|
||||
try writer.writeAll(" PUBLIC \"");
|
||||
try writeEscapedAttributeValue(writer, public_id);
|
||||
try writer.writeAll("\" \"");
|
||||
try writeEscapedAttributeValue(writer, system_id);
|
||||
try writer.writeAll("\"");
|
||||
} else if (public_id.len != 0) {
|
||||
try writer.writeAll(" PUBLIC \"");
|
||||
try writeEscapedAttributeValue(writer, public_id);
|
||||
try writer.writeAll("\"");
|
||||
} else if (system_id.len != 0) {
|
||||
try writer.writeAll(" SYSTEM \"");
|
||||
try writeEscapedAttributeValue(writer, system_id);
|
||||
try writer.writeAll("\"");
|
||||
}
|
||||
// Internal subset is not implemented
|
||||
try writer.writeAll(">");
|
||||
}
|
||||
|
||||
pub fn writeNode(node: *parser.Node, writer: anytype) anyerror!void {
|
||||
switch (try parser.nodeType(node)) {
|
||||
.element => {
|
||||
// open the tag
|
||||
const tag = try parser.nodeLocalName(node);
|
||||
try writer.writeAll("<");
|
||||
try writer.writeAll(tag);
|
||||
|
||||
// write the attributes
|
||||
const _map = try parser.nodeGetAttributes(node);
|
||||
if (_map) |map| {
|
||||
const ln = try parser.namedNodeMapGetLength(map);
|
||||
for (0..ln) |i| {
|
||||
const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse break;
|
||||
try writer.writeAll(" ");
|
||||
try writer.writeAll(try parser.attributeGetName(attr));
|
||||
try writer.writeAll("=\"");
|
||||
const attribute_value = try parser.attributeGetValue(attr) orelse "";
|
||||
try writeEscapedAttributeValue(writer, attribute_value);
|
||||
try writer.writeAll("\"");
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeAll(">");
|
||||
|
||||
// void elements can't have any content.
|
||||
if (try isVoid(parser.nodeToElement(node))) return;
|
||||
|
||||
if (try parser.elementHTMLGetTagType(@ptrCast(node)) == .script) {
|
||||
try writer.writeAll(try parser.nodeTextContent(node) orelse "");
|
||||
} else {
|
||||
// write the children
|
||||
// TODO avoid recursion
|
||||
try writeChildren(node, writer);
|
||||
}
|
||||
|
||||
// close the tag
|
||||
try writer.writeAll("</");
|
||||
try writer.writeAll(tag);
|
||||
try writer.writeAll(">");
|
||||
},
|
||||
.text => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writeEscapedTextNode(writer, v);
|
||||
},
|
||||
.cdata_section => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writer.writeAll("<![CDATA[");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("]]>");
|
||||
},
|
||||
.comment => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writer.writeAll("<!--");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("-->");
|
||||
},
|
||||
// TODO handle processing instruction dump
|
||||
.processing_instruction => return,
|
||||
// document fragment is outside of the main document DOM, so we
|
||||
// don't output it.
|
||||
.document_fragment => return,
|
||||
// document will never be called, but required for completeness.
|
||||
.document => return,
|
||||
// done globally instead, but required for completeness. Only the outer DOCTYPE should be written
|
||||
.document_type => return,
|
||||
// deprecated
|
||||
.attribute => return,
|
||||
.entity_reference => return,
|
||||
.entity => return,
|
||||
.notation => return,
|
||||
}
|
||||
}
|
||||
|
||||
// writer must be a std.io.Writer
|
||||
pub fn writeChildren(root: *parser.Node, writer: anytype) !void {
|
||||
pub fn writeNode(root: *parser.Node, writer: anytype) !void {
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = try walker.get_next(root, next) orelse break;
|
||||
try writeNode(next.?, writer);
|
||||
switch (try parser.nodeType(next.?)) {
|
||||
.element => {
|
||||
// open the tag
|
||||
const tag = try parser.nodeLocalName(next.?);
|
||||
try writer.writeAll("<");
|
||||
try writer.writeAll(tag);
|
||||
|
||||
// write the attributes
|
||||
const map = try parser.nodeGetAttributes(next.?);
|
||||
const ln = try parser.namedNodeMapGetLength(map);
|
||||
var i: u32 = 0;
|
||||
while (i < ln) {
|
||||
const attr = try parser.namedNodeMapItem(map, i) orelse break;
|
||||
try writer.writeAll(" ");
|
||||
try writer.writeAll(try parser.attributeGetName(attr));
|
||||
try writer.writeAll("=\"");
|
||||
try writer.writeAll(try parser.attributeGetValue(attr) orelse "");
|
||||
try writer.writeAll("\"");
|
||||
i += 1;
|
||||
}
|
||||
|
||||
try writer.writeAll(">");
|
||||
|
||||
// void elements can't have any content.
|
||||
if (try isVoid(parser.nodeToElement(next.?))) continue;
|
||||
|
||||
// write the children
|
||||
// TODO avoid recursion
|
||||
try writeNode(next.?, writer);
|
||||
|
||||
// close the tag
|
||||
try writer.writeAll("</");
|
||||
try writer.writeAll(tag);
|
||||
try writer.writeAll(">");
|
||||
},
|
||||
.text => {
|
||||
const v = try parser.nodeValue(next.?) orelse continue;
|
||||
try writer.writeAll(v);
|
||||
},
|
||||
.cdata_section => {
|
||||
const v = try parser.nodeValue(next.?) orelse continue;
|
||||
try writer.writeAll("<![CDATA[");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("]]>");
|
||||
},
|
||||
.comment => {
|
||||
const v = try parser.nodeValue(next.?) orelse continue;
|
||||
try writer.writeAll("<!--");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("-->");
|
||||
},
|
||||
// TODO handle processing instruction dump
|
||||
.processing_instruction => continue,
|
||||
// document fragment is outside of the main document DOM, so we
|
||||
// don't output it.
|
||||
.document_fragment => continue,
|
||||
// document will never be called, but required for completeness.
|
||||
.document => continue,
|
||||
// done globally instead, but required for completeness.
|
||||
.document_type => continue,
|
||||
// deprecated
|
||||
.attribute => continue,
|
||||
.entity_reference => continue,
|
||||
.entity => continue,
|
||||
.notation => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,95 +115,18 @@ fn isVoid(elem: *parser.Element) !bool {
|
||||
};
|
||||
}
|
||||
|
||||
fn writeEscapedTextNode(writer: anytype, value: []const u8) !void {
|
||||
var v = value;
|
||||
while (v.len > 0) {
|
||||
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>' }) orelse {
|
||||
return writer.writeAll(v);
|
||||
};
|
||||
try writer.writeAll(v[0..index]);
|
||||
switch (v[index]) {
|
||||
'&' => try writer.writeAll("&"),
|
||||
'<' => try writer.writeAll("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
else => unreachable,
|
||||
}
|
||||
v = v[index + 1 ..];
|
||||
}
|
||||
}
|
||||
|
||||
fn writeEscapedAttributeValue(writer: anytype, value: []const u8) !void {
|
||||
var v = value;
|
||||
while (v.len > 0) {
|
||||
const index = std.mem.indexOfAnyPos(u8, v, 0, &.{ '&', '<', '>', '"' }) orelse {
|
||||
return writer.writeAll(v);
|
||||
};
|
||||
try writer.writeAll(v[0..index]);
|
||||
switch (v[index]) {
|
||||
'&' => try writer.writeAll("&"),
|
||||
'<' => try writer.writeAll("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
'"' => try writer.writeAll("""),
|
||||
else => unreachable,
|
||||
}
|
||||
v = v[index + 1 ..];
|
||||
}
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
test "dump.writeHTML" {
|
||||
try parser.init();
|
||||
defer parser.deinit();
|
||||
const out = try std.fs.openFileAbsolute("/dev/null", .{ .mode = .write_only });
|
||||
defer out.close();
|
||||
|
||||
try testWriteHTML(
|
||||
"<div id=\"content\">Over 9000!</div>",
|
||||
"<div id=\"content\">Over 9000!</div>",
|
||||
);
|
||||
const file = try std.fs.cwd().openFile("test.html", .{});
|
||||
defer file.close();
|
||||
|
||||
try testWriteHTML(
|
||||
"<root><!-- a comment --></root>",
|
||||
"<root><!-- a comment --></root>",
|
||||
);
|
||||
|
||||
try testWriteHTML(
|
||||
"<p>< > &</p>",
|
||||
"<p>< > &</p>",
|
||||
);
|
||||
|
||||
try testWriteHTML(
|
||||
"<p id=\""><&"''\">wat?</p>",
|
||||
"<p id='\"><&"'''>wat?</p>",
|
||||
);
|
||||
|
||||
try testWriteFullHTML(
|
||||
\\<!DOCTYPE html>
|
||||
\\<html><head><title>It's over what?</title><meta name="a" value="b">
|
||||
\\</head><body>9000</body></html>
|
||||
\\
|
||||
, "<html><title>It's over what?</title><meta name=a value=\"b\">\n<body>9000");
|
||||
|
||||
try testWriteHTML(
|
||||
"<p>hi</p><script>alert(power > 9000)</script>",
|
||||
"<p>hi</p><script>alert(power > 9000)</script>",
|
||||
);
|
||||
}
|
||||
|
||||
fn testWriteHTML(comptime expected_body: []const u8, src: []const u8) !void {
|
||||
const expected =
|
||||
"<!DOCTYPE html>\n<html><head></head><body>" ++
|
||||
expected_body ++
|
||||
"</body></html>\n";
|
||||
return testWriteFullHTML(expected, src);
|
||||
}
|
||||
|
||||
fn testWriteFullHTML(comptime expected: []const u8, src: []const u8) !void {
|
||||
var buf = std.ArrayListUnmanaged(u8){};
|
||||
defer buf.deinit(testing.allocator);
|
||||
|
||||
const doc_html = try parser.documentHTMLParseFromStr(src);
|
||||
const doc_html = try parser.documentHTMLParse(file.reader(), "UTF-8");
|
||||
// ignore close error
|
||||
defer parser.documentHTMLClose(doc_html) catch {};
|
||||
|
||||
const doc = parser.documentHTMLToDocument(doc_html);
|
||||
try writeHTML(doc, buf.writer(testing.allocator));
|
||||
try testing.expectEqualStrings(expected, buf.items);
|
||||
|
||||
try writeHTML(doc, out);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
const std = @import("std");
|
||||
|
||||
const Page = @import("page.zig").Page;
|
||||
const js = @import("../runtime/js.zig");
|
||||
const generate = @import("../runtime/generate.zig");
|
||||
|
||||
const WebApis = struct {
|
||||
// Wrapped like this for debug ergonomics.
|
||||
// When we create our Env, a few lines down, we define it as:
|
||||
// pub const Env = js.Env(*Page, WebApis);
|
||||
//
|
||||
// If there's a compile time error witht he Env, it's type will be readable,
|
||||
// i.e.: runtime.js.Env(*browser.env.Page, browser.env.WebApis)
|
||||
//
|
||||
// But if we didn't wrap it in the struct, like we once didn't, and defined
|
||||
// env as:
|
||||
// pub const Env = js.Env(*Page, Interfaces);
|
||||
//
|
||||
// Because Interfaces is an anynoumous type, it doesn't have a friendly name
|
||||
// and errors would be something like:
|
||||
// runtime.js.Env(*browser.Page, .{...A HUNDRED TYPES...})
|
||||
pub const Interfaces = generate.Tuple(.{
|
||||
@import("crypto/crypto.zig").Crypto,
|
||||
@import("console/console.zig").Console,
|
||||
@import("css/css.zig").Interfaces,
|
||||
@import("cssom/cssom.zig").Interfaces,
|
||||
@import("dom/dom.zig").Interfaces,
|
||||
@import("dom/shadow_root.zig").ShadowRoot,
|
||||
@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 Function = Env.Function;
|
||||
pub const Env = js.Env(*Page, WebApis);
|
||||
pub const Global = @import("html/window.zig").Window;
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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 parser = @import("../netsurf.zig");
|
||||
const Event = @import("event.zig").Event;
|
||||
const JsObject = @import("../env.zig").JsObject;
|
||||
|
||||
// https://dom.spec.whatwg.org/#interface-customevent
|
||||
pub const CustomEvent = struct {
|
||||
pub const prototype = *Event;
|
||||
pub const union_make_copy = true;
|
||||
|
||||
proto: parser.Event,
|
||||
detail: ?JsObject,
|
||||
|
||||
const CustomEventInit = struct {
|
||||
bubbles: bool = false,
|
||||
cancelable: bool = false,
|
||||
composed: bool = false,
|
||||
detail: ?JsObject = null,
|
||||
};
|
||||
|
||||
pub fn constructor(event_type: []const u8, opts_: ?CustomEventInit) !CustomEvent {
|
||||
const opts = opts_ orelse CustomEventInit{};
|
||||
|
||||
const event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(event);
|
||||
try parser.eventInit(event, event_type, .{
|
||||
.bubbles = opts.bubbles,
|
||||
.cancelable = opts.cancelable,
|
||||
.composed = opts.composed,
|
||||
});
|
||||
|
||||
return .{
|
||||
.proto = event.*,
|
||||
.detail = if (opts.detail) |d| try d.persist() else null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_detail(self: *CustomEvent) ?JsObject {
|
||||
return self.detail;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.CustomEvent" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let capture = null", "undefined" },
|
||||
.{ "const el = document.createElement('div');", "undefined" },
|
||||
.{ "el.addEventListener('c1', (e) => { capture = 'c1-' + new String(e.detail)})", "undefined" },
|
||||
.{ "el.addEventListener('c2', (e) => { capture = 'c2-' + new String(e.detail.over)})", "undefined" },
|
||||
|
||||
.{ "el.dispatchEvent(new CustomEvent('c1'));", "true" },
|
||||
.{ "capture", "c1-null" },
|
||||
|
||||
.{ "el.dispatchEvent(new CustomEvent('c1', {detail: '123'}));", "true" },
|
||||
.{ "capture", "c1-123" },
|
||||
|
||||
.{ "el.dispatchEvent(new CustomEvent('c2', {detail: {over: 9000}}));", "true" },
|
||||
.{ "capture", "c2-9000" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const generate = @import("../../runtime/generate.zig");
|
||||
|
||||
const Page = @import("../page.zig").Page;
|
||||
const DOMException = @import("../dom/exceptions.zig").DOMException;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const EventTargetUnion = @import("../dom/event_target.zig").Union;
|
||||
const AbortSignal = @import("../html/AbortController.zig").AbortSignal;
|
||||
|
||||
const CustomEvent = @import("custom_event.zig").CustomEvent;
|
||||
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
|
||||
const MouseEvent = @import("mouse_event.zig").MouseEvent;
|
||||
const ErrorEvent = @import("../html/error_event.zig").ErrorEvent;
|
||||
|
||||
// Event interfaces
|
||||
pub const Interfaces = .{ Event, CustomEvent, ProgressEvent, MouseEvent, ErrorEvent };
|
||||
|
||||
pub const Union = generate.Union(Interfaces);
|
||||
|
||||
// https://dom.spec.whatwg.org/#event
|
||||
pub const Event = struct {
|
||||
pub const Self = parser.Event;
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub const EventInit = parser.EventInit;
|
||||
|
||||
// JS
|
||||
// --
|
||||
|
||||
pub const _CAPTURING_PHASE = 1;
|
||||
pub const _AT_TARGET = 2;
|
||||
pub const _BUBBLING_PHASE = 3;
|
||||
|
||||
pub fn toInterface(evt: *parser.Event) !Union {
|
||||
return switch (try parser.eventGetInternalType(evt)) {
|
||||
.event, .abort_signal, .xhr_event => .{ .Event = evt },
|
||||
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
|
||||
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
|
||||
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @ptrCast(evt)) },
|
||||
.error_event => .{ .ErrorEvent = @as(*ErrorEvent, @ptrCast(evt)).* },
|
||||
};
|
||||
}
|
||||
|
||||
pub fn constructor(event_type: []const u8, opts: ?EventInit) !*parser.Event {
|
||||
const event = try parser.eventCreate();
|
||||
try parser.eventInit(event, event_type, opts orelse EventInit{});
|
||||
return event;
|
||||
}
|
||||
|
||||
// Getters
|
||||
|
||||
pub fn get_type(self: *parser.Event) ![]const u8 {
|
||||
return try parser.eventType(self);
|
||||
}
|
||||
|
||||
pub fn get_target(self: *parser.Event, page: *Page) !?EventTargetUnion {
|
||||
const et = try parser.eventTarget(self);
|
||||
if (et == null) return null;
|
||||
return try EventTarget.toInterface(self, et.?, page);
|
||||
}
|
||||
|
||||
pub fn get_currentTarget(self: *parser.Event, page: *Page) !?EventTargetUnion {
|
||||
const et = try parser.eventCurrentTarget(self);
|
||||
if (et == null) return null;
|
||||
return try EventTarget.toInterface(self, et.?, page);
|
||||
}
|
||||
|
||||
pub fn get_eventPhase(self: *parser.Event) !u8 {
|
||||
return try parser.eventPhase(self);
|
||||
}
|
||||
|
||||
pub fn get_bubbles(self: *parser.Event) !bool {
|
||||
return try parser.eventBubbles(self);
|
||||
}
|
||||
|
||||
pub fn get_cancelable(self: *parser.Event) !bool {
|
||||
return try parser.eventCancelable(self);
|
||||
}
|
||||
|
||||
pub fn get_defaultPrevented(self: *parser.Event) !bool {
|
||||
return try parser.eventDefaultPrevented(self);
|
||||
}
|
||||
|
||||
pub fn get_isTrusted(self: *parser.Event) !bool {
|
||||
return try parser.eventIsTrusted(self);
|
||||
}
|
||||
|
||||
pub fn get_timestamp(self: *parser.Event) !u32 {
|
||||
return try parser.eventTimestamp(self);
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
pub fn _initEvent(
|
||||
self: *parser.Event,
|
||||
eventType: []const u8,
|
||||
bubbles: ?bool,
|
||||
cancelable: ?bool,
|
||||
) !void {
|
||||
const opts = EventInit{
|
||||
.bubbles = bubbles orelse false,
|
||||
.cancelable = cancelable orelse false,
|
||||
};
|
||||
return try parser.eventInit(self, eventType, opts);
|
||||
}
|
||||
|
||||
pub fn _stopPropagation(self: *parser.Event) !void {
|
||||
return try parser.eventStopPropagation(self);
|
||||
}
|
||||
|
||||
pub fn _stopImmediatePropagation(self: *parser.Event) !void {
|
||||
return try parser.eventStopImmediatePropagation(self);
|
||||
}
|
||||
|
||||
pub fn _preventDefault(self: *parser.Event) !void {
|
||||
return try parser.eventPreventDefault(self);
|
||||
}
|
||||
};
|
||||
|
||||
pub const EventHandler = struct {
|
||||
once: bool,
|
||||
capture: bool,
|
||||
callback: Function,
|
||||
node: parser.EventNode,
|
||||
listener: *parser.EventListener,
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Function = Env.Function;
|
||||
|
||||
pub const Listener = union(enum) {
|
||||
function: Function,
|
||||
object: Env.JsObject,
|
||||
|
||||
pub fn callback(self: Listener, target: *parser.EventTarget) !?Function {
|
||||
return switch (self) {
|
||||
.function => |func| try func.withThis(target),
|
||||
.object => |obj| blk: {
|
||||
const func = (try obj.getFunction("handleEvent")) orelse return null;
|
||||
break :blk try func.withThis(try obj.persist());
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Opts = union(enum) {
|
||||
flags: Flags,
|
||||
capture: bool,
|
||||
|
||||
const Flags = struct {
|
||||
once: ?bool,
|
||||
capture: ?bool,
|
||||
// We ignore this property. It seems to be largely used to help the
|
||||
// browser make certain performance tweaks (i.e. the browser knows
|
||||
// that the listener won't call preventDefault() and thus can safely
|
||||
// run the default as needed).
|
||||
passive: ?bool,
|
||||
signal: ?*AbortSignal, // currently does nothing
|
||||
};
|
||||
};
|
||||
|
||||
pub fn register(
|
||||
allocator: Allocator,
|
||||
target: *parser.EventTarget,
|
||||
typ: []const u8,
|
||||
listener: Listener,
|
||||
opts_: ?Opts,
|
||||
) !?*EventHandler {
|
||||
var once = false;
|
||||
var capture = false;
|
||||
var signal: ?*AbortSignal = null;
|
||||
|
||||
if (opts_) |opts| {
|
||||
switch (opts) {
|
||||
.capture => |c| capture = c,
|
||||
.flags => |f| {
|
||||
once = f.once orelse false;
|
||||
signal = f.signal orelse null;
|
||||
capture = f.capture orelse false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const callback = (try listener.callback(target)) orelse return null;
|
||||
|
||||
if (signal) |s| {
|
||||
const signal_target = parser.toEventTarget(AbortSignal, s);
|
||||
|
||||
const scb = try allocator.create(SignalCallback);
|
||||
scb.* = .{
|
||||
.target = target,
|
||||
.capture = capture,
|
||||
.callback_id = callback.id,
|
||||
.typ = try allocator.dupe(u8, typ),
|
||||
.signal_target = signal_target,
|
||||
.signal_listener = undefined,
|
||||
.node = .{ .func = SignalCallback.handle },
|
||||
};
|
||||
|
||||
scb.signal_listener = try parser.eventTargetAddEventListener(
|
||||
signal_target,
|
||||
"abort",
|
||||
&scb.node,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// check if event target has already this listener
|
||||
if (try parser.eventTargetHasListener(target, typ, capture, callback.id) != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eh = try allocator.create(EventHandler);
|
||||
eh.* = .{
|
||||
.once = once,
|
||||
.capture = capture,
|
||||
.callback = callback,
|
||||
.node = .{
|
||||
.id = callback.id,
|
||||
.func = handle,
|
||||
},
|
||||
.listener = undefined,
|
||||
};
|
||||
|
||||
eh.listener = try parser.eventTargetAddEventListener(
|
||||
target,
|
||||
typ,
|
||||
&eh.node,
|
||||
capture,
|
||||
);
|
||||
return eh;
|
||||
}
|
||||
|
||||
fn handle(node: *parser.EventNode, event: *parser.Event) void {
|
||||
const ievent = Event.toInterface(event) catch |err| {
|
||||
log.err(.app, "toInterface error", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
const self: *EventHandler = @fieldParentPtr("node", node);
|
||||
var result: Function.Result = undefined;
|
||||
self.callback.tryCall(void, .{ievent}, &result) catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "event handler",
|
||||
});
|
||||
};
|
||||
|
||||
if (self.once) {
|
||||
const target = (parser.eventTarget(event) catch return).?;
|
||||
const typ = parser.eventType(event) catch return;
|
||||
parser.eventTargetRemoveEventListener(
|
||||
target,
|
||||
typ,
|
||||
self.listener,
|
||||
self.capture,
|
||||
) catch {};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const SignalCallback = struct {
|
||||
typ: []const u8,
|
||||
capture: bool,
|
||||
callback_id: usize,
|
||||
node: parser.EventNode,
|
||||
target: *parser.EventTarget,
|
||||
signal_target: *parser.EventTarget,
|
||||
signal_listener: *parser.EventListener,
|
||||
|
||||
fn handle(node: *parser.EventNode, _: *parser.Event) void {
|
||||
const self: *SignalCallback = @fieldParentPtr("node", node);
|
||||
self._handle() catch |err| {
|
||||
log.err(.app, "event signal handler", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn _handle(self: *SignalCallback) !void {
|
||||
const lst = try parser.eventTargetHasListener(
|
||||
self.target,
|
||||
self.typ,
|
||||
self.capture,
|
||||
self.callback_id,
|
||||
);
|
||||
if (lst == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try parser.eventTargetRemoveEventListener(
|
||||
self.target,
|
||||
self.typ,
|
||||
lst.?,
|
||||
self.capture,
|
||||
);
|
||||
|
||||
// remove the abort signal listener itself
|
||||
try parser.eventTargetRemoveEventListener(
|
||||
self.signal_target,
|
||||
"abort",
|
||||
self.signal_listener,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Event" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let content = document.getElementById('content')", "undefined" },
|
||||
.{ "let para = document.getElementById('para')", "undefined" },
|
||||
.{ "var nb = 0; var evt", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ content.addEventListener('target', function(e) {
|
||||
\\ evt = e; nb = nb + 1;
|
||||
\\ e.preventDefault();
|
||||
\\ })
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
.{ "content.dispatchEvent(new Event('target', {bubbles: true, cancelable: true}))", "false" },
|
||||
.{ "nb", "1" },
|
||||
.{ "evt.target === content", "true" },
|
||||
.{ "evt.bubbles", "true" },
|
||||
.{ "evt.cancelable", "true" },
|
||||
.{ "evt.defaultPrevented", "true" },
|
||||
.{ "evt.isTrusted", "true" },
|
||||
.{ "evt.timestamp > 1704063600", "true" }, // 2024/01/01 00:00
|
||||
// event.type, event.currentTarget, event.phase checked in EventTarget
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{
|
||||
\\ content.addEventListener('stop',function(e) {
|
||||
\\ e.stopPropagation();
|
||||
\\ nb = nb + 1;
|
||||
\\ }, true)
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
// the following event listener will not be invoked
|
||||
.{
|
||||
\\ para.addEventListener('stop',function(e) {
|
||||
\\ nb = nb + 1;
|
||||
\\ })
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
.{ "para.dispatchEvent(new Event('stop'))", "true" },
|
||||
.{ "nb", "1" }, // will be 2 if event was not stopped at content event listener
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{
|
||||
\\ content.addEventListener('immediate', function(e) {
|
||||
\\ e.stopImmediatePropagation();
|
||||
\\ nb = nb + 1;
|
||||
\\ })
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
// the following event listener will not be invoked
|
||||
.{
|
||||
\\ content.addEventListener('immediate', function(e) {
|
||||
\\ nb = nb + 1;
|
||||
\\ })
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
.{ "content.dispatchEvent(new Event('immediate'))", "true" },
|
||||
.{ "nb", "1" }, // will be 2 if event was not stopped at first content event listener
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0", "0" },
|
||||
.{
|
||||
\\ content.addEventListener('legacy', function(e) {
|
||||
\\ evt = e; nb = nb + 1;
|
||||
\\ })
|
||||
,
|
||||
"undefined",
|
||||
},
|
||||
.{ "let evtLegacy = document.createEvent('Event')", "undefined" },
|
||||
.{ "evtLegacy.initEvent('legacy')", "undefined" },
|
||||
.{ "content.dispatchEvent(evtLegacy)", "true" },
|
||||
.{ "nb", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var nb = 0; var evt = null; function cbk(event) { nb ++; evt=event; }", "undefined" },
|
||||
.{ "document.addEventListener('count', cbk)", "undefined" },
|
||||
.{ "document.removeEventListener('count', cbk)", "undefined" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "nb", "0" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; function cbk(event) { nb ++; }", null },
|
||||
.{ "document.addEventListener('count', cbk, {once: true})", null },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "nb", "1" },
|
||||
.{ "document.removeEventListener('count', cbk)", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "nb = 0; function cbk(event) { nb ++; }", null },
|
||||
.{ "let ac = new AbortController()", null },
|
||||
.{ "document.addEventListener('count', cbk, {signal: ac.signal})", null },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "ac.abort()", null },
|
||||
.{ "document.dispatchEvent(new Event('count'))", "true" },
|
||||
.{ "nb", "2" },
|
||||
.{ "document.removeEventListener('count', cbk)", "undefined" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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 log = std.log.scoped(.mouse_event);
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Event = @import("event.zig").Event;
|
||||
const JsObject = @import("../env.zig").JsObject;
|
||||
|
||||
// TODO: We currently don't have a UIEvent interface so we skip it in the prototype chain.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/UIEvent
|
||||
const UIEvent = Event;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent
|
||||
pub const MouseEvent = struct {
|
||||
pub const Self = parser.MouseEvent;
|
||||
pub const prototype = *UIEvent;
|
||||
|
||||
const MouseButton = enum(u16) {
|
||||
main_button = 0,
|
||||
auxillary_button = 1,
|
||||
secondary_button = 2,
|
||||
fourth_button = 3,
|
||||
fifth_button = 4,
|
||||
};
|
||||
|
||||
const MouseEventInit = struct {
|
||||
screenX: i32 = 0,
|
||||
screenY: i32 = 0,
|
||||
clientX: i32 = 0,
|
||||
clientY: i32 = 0,
|
||||
ctrlKey: bool = false,
|
||||
shiftKey: bool = false,
|
||||
altKey: bool = false,
|
||||
metaKey: bool = false,
|
||||
button: MouseButton = .main_button,
|
||||
};
|
||||
|
||||
pub fn constructor(event_type: []const u8, opts_: ?MouseEventInit) !*parser.MouseEvent {
|
||||
const opts = opts_ orelse MouseEventInit{};
|
||||
|
||||
var mouse_event = try parser.mouseEventCreate();
|
||||
try parser.eventSetInternalType(@ptrCast(&mouse_event), .mouse_event);
|
||||
|
||||
try parser.mouseEventInit(mouse_event, event_type, .{
|
||||
.x = opts.clientX,
|
||||
.y = opts.clientY,
|
||||
.ctrl = opts.ctrlKey,
|
||||
.shift = opts.shiftKey,
|
||||
.alt = opts.altKey,
|
||||
.meta = opts.metaKey,
|
||||
.button = @intFromEnum(opts.button),
|
||||
});
|
||||
|
||||
if (!std.mem.eql(u8, event_type, "click")) {
|
||||
log.warn("MouseEvent currently only supports listeners for 'click' events!", .{});
|
||||
}
|
||||
|
||||
return mouse_event;
|
||||
}
|
||||
|
||||
pub fn get_button(self: *parser.MouseEvent) u16 {
|
||||
return self.button;
|
||||
}
|
||||
|
||||
// These is just an alias for clientX.
|
||||
pub fn get_x(self: *parser.MouseEvent) i32 {
|
||||
return self.cx;
|
||||
}
|
||||
|
||||
// These is just an alias for clientY.
|
||||
pub fn get_y(self: *parser.MouseEvent) i32 {
|
||||
return self.cy;
|
||||
}
|
||||
|
||||
pub fn get_clientX(self: *parser.MouseEvent) i32 {
|
||||
return self.cx;
|
||||
}
|
||||
|
||||
pub fn get_clientY(self: *parser.MouseEvent) i32 {
|
||||
return self.cy;
|
||||
}
|
||||
|
||||
pub fn get_screenX(self: *parser.MouseEvent) i32 {
|
||||
return self.sx;
|
||||
}
|
||||
|
||||
pub fn get_screenY(self: *parser.MouseEvent) i32 {
|
||||
return self.sy;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.MouseEvent" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
// Default MouseEvent
|
||||
.{ "let event = new MouseEvent('click')", "undefined" },
|
||||
.{ "event.type", "click" },
|
||||
.{ "event instanceof MouseEvent", "true" },
|
||||
.{ "event instanceof Event", "true" },
|
||||
.{ "event.clientX", "0" },
|
||||
.{ "event.clientY", "0" },
|
||||
.{ "event.screenX", "0" },
|
||||
.{ "event.screenY", "0" },
|
||||
// MouseEvent with parameters
|
||||
.{ "let new_event = new MouseEvent('click', { 'button': 0, 'clientX': 10, 'clientY': 20 })", "undefined" },
|
||||
.{ "new_event.button", "0" },
|
||||
.{ "new_event.x", "10" },
|
||||
.{ "new_event.y", "20" },
|
||||
.{ "new_event.screenX", "10" },
|
||||
.{ "new_event.screenY", "20" },
|
||||
// MouseEvent Listener
|
||||
.{ "let me = new MouseEvent('click')", "undefined" },
|
||||
.{ "me instanceof Event", "true" },
|
||||
.{ "var eevt = null; function ccbk(event) { eevt = event; }", "undefined" },
|
||||
.{ "document.addEventListener('click', ccbk)", "undefined" },
|
||||
.{ "document.dispatchEvent(me)", "true" },
|
||||
.{ "eevt.type", "click" },
|
||||
.{ "eevt instanceof MouseEvent", "true" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
pub const Interfaces = .{
|
||||
AbortController,
|
||||
AbortSignal,
|
||||
};
|
||||
|
||||
const AbortController = @This();
|
||||
|
||||
signal: *AbortSignal,
|
||||
|
||||
pub fn constructor(page: *Page) !AbortController {
|
||||
// Why do we allocate this rather than storing directly in the struct?
|
||||
// https://github.com/lightpanda-io/project/discussions/165
|
||||
const signal = try page.arena.create(AbortSignal);
|
||||
signal.* = .init;
|
||||
|
||||
return .{
|
||||
.signal = signal,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_signal(self: *AbortController) *AbortSignal {
|
||||
return self.signal;
|
||||
}
|
||||
|
||||
pub fn _abort(self: *AbortController, reason_: ?[]const u8) !void {
|
||||
return self.signal.abort(reason_);
|
||||
}
|
||||
|
||||
pub const AbortSignal = struct {
|
||||
const DEFAULT_REASON = "AbortError";
|
||||
|
||||
pub const prototype = *EventTarget;
|
||||
proto: parser.EventTargetTBase = .{},
|
||||
|
||||
aborted: bool,
|
||||
reason: ?[]const u8,
|
||||
|
||||
pub const init: AbortSignal = .{
|
||||
.proto = .{},
|
||||
.reason = null,
|
||||
.aborted = false,
|
||||
};
|
||||
|
||||
pub fn static_abort(reason_: ?[]const u8) AbortSignal {
|
||||
return .{
|
||||
.aborted = true,
|
||||
.reason = reason_ orelse DEFAULT_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn static_timeout(delay: u32, page: *Page) !*AbortSignal {
|
||||
const callback = try page.arena.create(TimeoutCallback);
|
||||
callback.* = .{
|
||||
.signal = .init,
|
||||
.node = .{ .func = TimeoutCallback.run },
|
||||
};
|
||||
|
||||
const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms;
|
||||
_ = try page.loop.timeout(delay_ms, &callback.node);
|
||||
return &callback.signal;
|
||||
}
|
||||
|
||||
pub fn get_aborted(self: *const AbortSignal) bool {
|
||||
return self.aborted;
|
||||
}
|
||||
|
||||
fn abort(self: *AbortSignal, reason_: ?[]const u8) !void {
|
||||
self.aborted = true;
|
||||
self.reason = reason_ orelse DEFAULT_REASON;
|
||||
|
||||
const abort_event = try parser.eventCreate();
|
||||
try parser.eventSetInternalType(abort_event, .abort_signal);
|
||||
|
||||
defer parser.eventDestroy(abort_event);
|
||||
try parser.eventInit(abort_event, "abort", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(AbortSignal, self),
|
||||
abort_event,
|
||||
);
|
||||
}
|
||||
|
||||
const Reason = union(enum) {
|
||||
reason: []const u8,
|
||||
undefined: void,
|
||||
};
|
||||
pub fn get_reason(self: *const AbortSignal) Reason {
|
||||
if (self.reason) |r| {
|
||||
return .{ .reason = r };
|
||||
}
|
||||
return .{ .undefined = {} };
|
||||
}
|
||||
|
||||
const ThrowIfAborted = union(enum) {
|
||||
exception: Env.Exception,
|
||||
undefined: void,
|
||||
};
|
||||
pub fn _throwIfAborted(self: *const AbortSignal, page: *Page) ThrowIfAborted {
|
||||
if (self.aborted) {
|
||||
const ex = page.main_context.throw(self.reason orelse DEFAULT_REASON);
|
||||
return .{ .exception = ex };
|
||||
}
|
||||
return .{ .undefined = {} };
|
||||
}
|
||||
};
|
||||
|
||||
const TimeoutCallback = struct {
|
||||
signal: AbortSignal,
|
||||
|
||||
// This is the internal data that the event loop tracks. We'll get this
|
||||
// back in run and, from it, can get our TimeoutCallback instance
|
||||
node: Loop.CallbackNode = undefined,
|
||||
|
||||
fn run(node: *Loop.CallbackNode, _: *?u63) void {
|
||||
const self: *TimeoutCallback = @fieldParentPtr("node", node);
|
||||
self.signal.abort("TimeoutError") catch |err| {
|
||||
log.warn(.app, "abort signal timeout", .{ .err = err });
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.AbortController" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var called = 0", null },
|
||||
.{ "var a1 = new AbortController()", null },
|
||||
.{ "var s1 = a1.signal", null },
|
||||
.{ "s1.throwIfAborted()", "undefined" },
|
||||
.{ "s1.reason", "undefined" },
|
||||
.{ "var target;", null },
|
||||
.{
|
||||
\\ s1.addEventListener('abort', (e) => {
|
||||
\\ called += 1;
|
||||
\\ target = e.target;
|
||||
\\
|
||||
\\ });
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "a1.abort()", null },
|
||||
.{ "s1.aborted", "true" },
|
||||
.{ "target == s1", "true" },
|
||||
.{ "s1.reason", "AbortError" },
|
||||
.{ "called", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var s2 = AbortSignal.abort('over 9000')", null },
|
||||
.{ "s2.aborted", "true" },
|
||||
.{ "s2.reason", "over 9000" },
|
||||
.{ "AbortSignal.abort().reason", "AbortError" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var s3 = AbortSignal.timeout(10)", null },
|
||||
.{ "s3.aborted", "true" },
|
||||
.{ "s3.reason", "TimeoutError" },
|
||||
.{ "try { s3.throwIfAborted() } catch (e) { e }", "Error: TimeoutError" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const DataSet = @This();
|
||||
|
||||
element: *parser.Element,
|
||||
|
||||
pub fn named_get(self: *const DataSet, name: []const u8, _: *bool, page: *Page) !Env.UndefinedOr([]const u8) {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
if (try parser.elementGetAttribute(self.element, normalized_name)) |value| {
|
||||
return .{ .value = value };
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
|
||||
pub fn named_set(self: *DataSet, name: []const u8, value: []const u8, _: *bool, page: *Page) !void {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
try parser.elementSetAttribute(self.element, normalized_name, value);
|
||||
}
|
||||
|
||||
pub fn named_delete(self: *DataSet, name: []const u8, _: *bool, page: *Page) !void {
|
||||
const normalized_name = try normalize(page.call_arena, name);
|
||||
try parser.elementRemoveAttribute(self.element, normalized_name);
|
||||
}
|
||||
|
||||
fn normalize(allocator: Allocator, name: []const u8) ![]const u8 {
|
||||
var upper_count: usize = 0;
|
||||
for (name) |c| {
|
||||
if (std.ascii.isUpper(c)) {
|
||||
upper_count += 1;
|
||||
}
|
||||
}
|
||||
// for every upper-case letter, we'll probably need a dash before it
|
||||
// and we need the 'data-' prefix
|
||||
var normalized = try allocator.alloc(u8, name.len + upper_count + 5);
|
||||
|
||||
@memcpy(normalized[0..5], "data-");
|
||||
if (upper_count == 0) {
|
||||
@memcpy(normalized[5..], name);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
var pos: usize = 5;
|
||||
for (name) |c| {
|
||||
if (std.ascii.isUpper(c)) {
|
||||
normalized[pos] = '-';
|
||||
pos += 1;
|
||||
normalized[pos] = c + 32;
|
||||
} else {
|
||||
normalized[pos] = c;
|
||||
}
|
||||
pos += 1;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.DataSet" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let el1 = document.createElement('div')", null },
|
||||
.{ "el1.dataset.x", "undefined" },
|
||||
.{ "el1.dataset.x = '123'", "123" },
|
||||
.{ "delete el1.dataset.x", "true" },
|
||||
.{ "el1.dataset.x", "undefined" },
|
||||
.{ "delete el1.dataset.other", "true" }, // yes, this is right
|
||||
|
||||
.{ "let ds1 = el1.dataset", null },
|
||||
.{ "ds1.helloWorld = 'yes'", null },
|
||||
.{ "el1.getAttribute('data-hello-world')", "yes" },
|
||||
.{ "el1.setAttribute('data-this-will-work', 'positive')", null },
|
||||
.{ "ds1.thisWillWork", "positive" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
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;
|
||||
|
||||
const collection = @import("../dom/html_collection.zig");
|
||||
const Walker = @import("../dom/walker.zig").WalkerDepthFirst;
|
||||
const Cookie = @import("../storage/cookie.zig").Cookie;
|
||||
|
||||
// WEB IDL https://html.spec.whatwg.org/#the-document-object
|
||||
pub const HTMLDocument = struct {
|
||||
pub const Self = parser.DocumentHTML;
|
||||
pub const prototype = *Document;
|
||||
pub const subtype = .node;
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
pub fn get_domain(self: *parser.DocumentHTML, page: *Page) ![]const u8 {
|
||||
// libdom's document_html get_domain always returns null, this is
|
||||
// the way MDN recommends getting the domain anyways, since document.domain
|
||||
// is deprecated.
|
||||
const location = try parser.documentHTMLGetLocation(Location, self) orelse return "";
|
||||
return location.get_host(page);
|
||||
}
|
||||
|
||||
pub fn set_domain(_: *parser.DocumentHTML, _: []const u8) ![]const u8 {
|
||||
return error.NotImplemented;
|
||||
}
|
||||
|
||||
pub fn get_referrer(self: *parser.DocumentHTML) ![]const u8 {
|
||||
return try parser.documentHTMLGetReferrer(self);
|
||||
}
|
||||
|
||||
pub fn set_referrer(_: *parser.DocumentHTML, _: []const u8) ![]const u8 {
|
||||
return error.NotImplemented;
|
||||
}
|
||||
|
||||
pub fn get_body(self: *parser.DocumentHTML) !?*parser.Body {
|
||||
return try parser.documentHTMLBody(self);
|
||||
}
|
||||
|
||||
pub fn set_body(self: *parser.DocumentHTML, elt: ?*parser.ElementHTML) !?*parser.Body {
|
||||
try parser.documentHTMLSetBody(self, elt);
|
||||
return try get_body(self);
|
||||
}
|
||||
|
||||
pub fn get_head(self: *parser.DocumentHTML) !?*parser.Head {
|
||||
const root = parser.documentHTMLToNode(self);
|
||||
const walker = Walker{};
|
||||
var next: ?*parser.Node = null;
|
||||
while (true) {
|
||||
next = try walker.get_next(root, next) orelse return null;
|
||||
if (std.ascii.eqlIgnoreCase("head", try parser.nodeName(next.?))) {
|
||||
return @as(*parser.Head, @ptrCast(next.?));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_cookie(_: *parser.DocumentHTML, page: *Page) ![]const u8 {
|
||||
var buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true, .is_http = false });
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn set_cookie(_: *parser.DocumentHTML, cookie_str: []const u8, page: *Page) ![]const u8 {
|
||||
// we use the cookie jar's allocator to parse the cookie because it
|
||||
// outlives the page's arena.
|
||||
const c = try Cookie.parse(page.cookie_jar.allocator, &page.url.uri, cookie_str);
|
||||
errdefer c.deinit();
|
||||
if (c.http_only) {
|
||||
c.deinit();
|
||||
return ""; // HttpOnly cookies cannot be set from JS
|
||||
}
|
||||
try page.cookie_jar.add(c, std.time.timestamp());
|
||||
return cookie_str;
|
||||
}
|
||||
|
||||
pub fn get_title(self: *parser.DocumentHTML) ![]const u8 {
|
||||
return try parser.documentHTMLGetTitle(self);
|
||||
}
|
||||
|
||||
pub fn set_title(self: *parser.DocumentHTML, v: []const u8) ![]const u8 {
|
||||
try parser.documentHTMLSetTitle(self, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
pub fn _getElementsByName(self: *parser.DocumentHTML, name: []const u8, page: *Page) !NodeList {
|
||||
const arena = page.arena;
|
||||
var list: NodeList = .{};
|
||||
|
||||
if (name.len == 0) return list;
|
||||
|
||||
const root = parser.documentHTMLToNode(self);
|
||||
var c = try collection.HTMLCollectionByName(arena, root, name, false);
|
||||
|
||||
const ln = try c.get_length();
|
||||
var i: u32 = 0;
|
||||
while (i < ln) {
|
||||
const n = try c.item(i) orelse break;
|
||||
try list.append(arena, n);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
pub fn get_images(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "img", false);
|
||||
}
|
||||
|
||||
pub fn get_embeds(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "embed", false);
|
||||
}
|
||||
|
||||
pub fn get_plugins(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
|
||||
return get_embeds(self, page);
|
||||
}
|
||||
|
||||
pub fn get_forms(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "form", false);
|
||||
}
|
||||
|
||||
pub fn get_scripts(self: *parser.DocumentHTML, page: *Page) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByTagName(page.arena, parser.documentHTMLToNode(self), "script", false);
|
||||
}
|
||||
|
||||
pub fn get_applets(_: *parser.DocumentHTML) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionEmpty();
|
||||
}
|
||||
|
||||
pub fn get_links(self: *parser.DocumentHTML) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByLinks(parser.documentHTMLToNode(self), false);
|
||||
}
|
||||
|
||||
pub fn get_anchors(self: *parser.DocumentHTML) !collection.HTMLCollection {
|
||||
return try collection.HTMLCollectionByAnchors(parser.documentHTMLToNode(self), false);
|
||||
}
|
||||
|
||||
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 {
|
||||
return try parser.documentHTMLGetCurrentScript(self);
|
||||
}
|
||||
|
||||
pub fn get_location(self: *parser.DocumentHTML) !?*Location {
|
||||
return try parser.documentHTMLGetLocation(Location, self);
|
||||
}
|
||||
|
||||
pub fn set_location(_: *const parser.DocumentHTML, url: []const u8, page: *Page) !void {
|
||||
return page.navigateFromWebAPI(url, .{ .reason = .script });
|
||||
}
|
||||
|
||||
pub fn get_designMode(_: *parser.DocumentHTML) []const u8 {
|
||||
return "off";
|
||||
}
|
||||
|
||||
pub fn set_designMode(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "off";
|
||||
}
|
||||
|
||||
pub fn get_defaultView(_: *parser.DocumentHTML, page: *Page) *Window {
|
||||
return &page.window;
|
||||
}
|
||||
|
||||
pub fn get_readyState(self: *parser.DocumentHTML, page: *Page) ![]const u8 {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
return @tagName(state.ready_state);
|
||||
}
|
||||
|
||||
// noop legacy functions
|
||||
// https://html.spec.whatwg.org/#Document-partial
|
||||
pub fn _clear(_: *parser.DocumentHTML) void {}
|
||||
pub fn _captureEvents(_: *parser.DocumentHTML) void {}
|
||||
pub fn _releaseEvents(_: *parser.DocumentHTML) void {}
|
||||
|
||||
pub fn get_fgColor(_: *parser.DocumentHTML) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn set_fgColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn get_linkColor(_: *parser.DocumentHTML) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn set_linkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn get_vlinkColor(_: *parser.DocumentHTML) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn set_vlinkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn get_alinkColor(_: *parser.DocumentHTML) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn set_alinkColor(_: *parser.DocumentHTML, _: []const u8) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn get_bgColor(_: *parser.DocumentHTML) []const u8 {
|
||||
return "";
|
||||
}
|
||||
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.
|
||||
// While x and y should be f32, here we take i32 since that's what our
|
||||
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
|
||||
// conversion ourself, we rely on v8's type conversion which is both more
|
||||
// flexible (e.g. handles NaN) and will be more consistent with a browser.
|
||||
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) !?ElementUnion {
|
||||
const element = page.renderer.getElementAtPosition(x, y) 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.
|
||||
// While x and y should be f32, here we take i32 since that's what our
|
||||
// "renderer" uses. By specifying i32 here, rather than f32 and doing the
|
||||
// conversion ourself, we rely on v8's type conversion which is both more
|
||||
// flexible (e.g. handles NaN) and will be more consistent with a browser.
|
||||
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: i32, y: i32, page: *Page) ![]ElementUnion {
|
||||
const element = page.renderer.getElementAtPosition(x, y) 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(page.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(page.window.document)) orelse {
|
||||
return list.items;
|
||||
};
|
||||
if (try parser.documentHTMLBody(page.window.document)) |body| {
|
||||
list.appendAssumeCapacity(try Element.toInterface(parser.bodyToElement(body)));
|
||||
}
|
||||
list.appendAssumeCapacity(try Element.toInterface(doc_elem));
|
||||
return list.items;
|
||||
}
|
||||
|
||||
pub fn documentIsLoaded(self: *parser.DocumentHTML, page: *Page) !void {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
state.ready_state = .interactive;
|
||||
|
||||
const evt = try parser.eventCreate();
|
||||
defer parser.eventDestroy(evt);
|
||||
|
||||
log.debug(.script_event, "dispatch event", .{
|
||||
.type = "DOMContentLoaded",
|
||||
.source = "document",
|
||||
});
|
||||
try parser.eventInit(evt, "DOMContentLoaded", .{ .bubbles = true, .cancelable = true });
|
||||
_ = try parser.eventTargetDispatchEvent(parser.toEventTarget(parser.DocumentHTML, self), evt);
|
||||
}
|
||||
|
||||
pub fn documentIsComplete(self: *parser.DocumentHTML, page: *Page) !void {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(self)));
|
||||
state.ready_state = .complete;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "Browser.HTML.Document" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.__proto__.constructor.name", "HTMLDocument" },
|
||||
.{ "document.__proto__.__proto__.constructor.name", "Document" },
|
||||
.{ "document.body.localName == 'body'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.domain", "lightpanda.io" },
|
||||
.{ "document.referrer", "" },
|
||||
.{ "document.title", "" },
|
||||
.{ "document.body.localName", "body" },
|
||||
.{ "document.head.localName", "head" },
|
||||
.{ "document.images.length", "0" },
|
||||
.{ "document.embeds.length", "0" },
|
||||
.{ "document.plugins.length", "0" },
|
||||
.{ "document.scripts.length", "0" },
|
||||
.{ "document.forms.length", "0" },
|
||||
.{ "document.links.length", "1" },
|
||||
.{ "document.applets.length", "0" },
|
||||
.{ "document.anchors.length", "0" },
|
||||
.{ "document.all.length", "8" },
|
||||
.{ "document.currentScript", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.title = 'foo'", "foo" },
|
||||
.{ "document.title", "foo" },
|
||||
.{ "document.title = ''", "" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.getElementById('link').setAttribute('name', 'foo')", "undefined" },
|
||||
.{ "let list = document.getElementsByName('foo')", "undefined" },
|
||||
.{ "list.length", "1" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.cookie", "" },
|
||||
.{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" },
|
||||
.{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" },
|
||||
.{ "document.cookie", "name=Oeschger; favorite_food=tripe" },
|
||||
.{ "document.cookie = 'IgnoreMy=Ghost; HttpOnly'", null }, // "" should be returned, but the framework overrules it atm
|
||||
.{ "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.page.window.document, runner.page);
|
||||
try runner.testCases(&.{
|
||||
.{ "document.readyState", "interactive" },
|
||||
}, .{});
|
||||
|
||||
try HTMLDocument.documentIsComplete(runner.page.window.document, runner.page);
|
||||
try runner.testCases(&.{
|
||||
.{ "document.readyState", "complete" },
|
||||
}, .{});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/ErrorEvent
|
||||
pub const ErrorEvent = struct {
|
||||
pub const prototype = *parser.Event;
|
||||
pub const union_make_copy = true;
|
||||
|
||||
proto: parser.Event,
|
||||
message: []const u8,
|
||||
filename: []const u8,
|
||||
lineno: i32,
|
||||
colno: i32,
|
||||
@"error": ?Env.JsObject,
|
||||
|
||||
const ErrorEventInit = struct {
|
||||
message: []const u8 = "",
|
||||
filename: []const u8 = "",
|
||||
lineno: i32 = 0,
|
||||
colno: i32 = 0,
|
||||
@"error": ?Env.JsObject = null,
|
||||
};
|
||||
|
||||
pub fn constructor(event_type: []const u8, opts: ?ErrorEventInit) !ErrorEvent {
|
||||
const event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(event);
|
||||
try parser.eventInit(event, event_type, .{});
|
||||
try parser.eventSetInternalType(event, .event);
|
||||
|
||||
const o = opts orelse ErrorEventInit{};
|
||||
|
||||
return .{
|
||||
.proto = event.*,
|
||||
.message = o.message,
|
||||
.filename = o.filename,
|
||||
.lineno = o.lineno,
|
||||
.colno = o.colno,
|
||||
.@"error" = if (o.@"error") |e| try e.persist() else null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_message(self: *const ErrorEvent) []const u8 {
|
||||
return self.message;
|
||||
}
|
||||
|
||||
pub fn get_filename(self: *const ErrorEvent) []const u8 {
|
||||
return self.filename;
|
||||
}
|
||||
|
||||
pub fn get_lineno(self: *const ErrorEvent) i32 {
|
||||
return self.lineno;
|
||||
}
|
||||
|
||||
pub fn get_colno(self: *const ErrorEvent) i32 {
|
||||
return self.colno;
|
||||
}
|
||||
|
||||
pub fn get_error(self: *const ErrorEvent) Env.UndefinedOr(Env.JsObject) {
|
||||
if (self.@"error") |e| {
|
||||
return .{ .value = e };
|
||||
}
|
||||
return .undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.ErrorEvent" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=c></div>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let e1 = new ErrorEvent('err1')", null },
|
||||
.{ "e1.message", "" },
|
||||
.{ "e1.filename", "" },
|
||||
.{ "e1.lineno", "0" },
|
||||
.{ "e1.colno", "0" },
|
||||
.{ "e1.error", "undefined" },
|
||||
|
||||
.{
|
||||
\\ let e2 = new ErrorEvent('err1', {
|
||||
\\ message: 'm1',
|
||||
\\ filename: 'fx19',
|
||||
\\ lineno: 443,
|
||||
\\ colno: 8999,
|
||||
\\ error: 'under 9000!',
|
||||
\\
|
||||
\\})
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "e2.message", "m1" },
|
||||
.{ "e2.filename", "fx19" },
|
||||
.{ "e2.lineno", "443" },
|
||||
.{ "e2.colno", "8999" },
|
||||
.{ "e2.error", "under 9000!" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
const URL = @import("../url/url.zig").URL;
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-location-interface
|
||||
pub const Location = struct {
|
||||
url: ?URL = null,
|
||||
|
||||
pub fn get_href(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_href(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_protocol(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_protocol(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_host(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_host(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_hostname(self: *Location) []const u8 {
|
||||
if (self.url) |*u| return u.get_hostname();
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_port(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_port(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_pathname(self: *Location) []const u8 {
|
||||
if (self.url) |*u| return u.get_pathname();
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_search(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_search(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_hash(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_hash(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn get_origin(self: *Location, page: *Page) ![]const u8 {
|
||||
if (self.url) |*u| return u.get_origin(page);
|
||||
return "";
|
||||
}
|
||||
|
||||
pub fn _assign(_: *const Location, url: []const u8, page: *Page) !void {
|
||||
return page.navigateFromWebAPI(url, .{ .reason = .script });
|
||||
}
|
||||
|
||||
pub fn _replace(_: *const Location, url: []const u8, page: *Page) !void {
|
||||
return page.navigateFromWebAPI(url, .{ .reason = .script });
|
||||
}
|
||||
|
||||
pub fn _reload(_: *const Location, page: *Page) !void {
|
||||
return page.navigateFromWebAPI(page.url.raw, .{ .reason = .script });
|
||||
}
|
||||
|
||||
pub fn _toString(self: *Location, page: *Page) ![]const u8 {
|
||||
return try self.get_href(page);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Location" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "location.href", "https://lightpanda.io/opensource-browser/" },
|
||||
.{ "document.location.href", "https://lightpanda.io/opensource-browser/" },
|
||||
|
||||
.{ "location.host", "lightpanda.io" },
|
||||
.{ "location.hostname", "lightpanda.io" },
|
||||
.{ "location.origin", "https://lightpanda.io" },
|
||||
.{ "location.pathname", "/opensource-browser/" },
|
||||
.{ "location.hash", "" },
|
||||
.{ "location.port", "" },
|
||||
.{ "location.search", "" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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 {}
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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 EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
pub const Interfaces = .{
|
||||
Screen,
|
||||
ScreenOrientation,
|
||||
};
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen
|
||||
pub const Screen = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
height: u32 = 1080,
|
||||
width: u32 = 1920,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/colorDepth
|
||||
color_depth: u32 = 8,
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/pixelDepth
|
||||
pixel_depth: u32 = 8,
|
||||
orientation: ScreenOrientation = .{ .type = .landscape_primary },
|
||||
|
||||
pub fn get_availHeight(self: *const Screen) u32 {
|
||||
return self.height;
|
||||
}
|
||||
|
||||
pub fn get_availWidth(self: *const Screen) u32 {
|
||||
return self.width;
|
||||
}
|
||||
|
||||
pub fn get_height(self: *const Screen) u32 {
|
||||
return self.height;
|
||||
}
|
||||
|
||||
pub fn get_width(self: *const Screen) u32 {
|
||||
return self.width;
|
||||
}
|
||||
|
||||
pub fn get_pixelDepth(self: *const Screen) u32 {
|
||||
return self.pixel_depth;
|
||||
}
|
||||
|
||||
pub fn get_orientation(self: *const Screen) ScreenOrientation {
|
||||
return self.orientation;
|
||||
}
|
||||
};
|
||||
|
||||
const ScreenOrientationType = enum {
|
||||
portrait_primary,
|
||||
portrait_secondary,
|
||||
landscape_primary,
|
||||
landscape_secondary,
|
||||
|
||||
pub fn toString(self: ScreenOrientationType) []const u8 {
|
||||
return switch (self) {
|
||||
.portrait_primary => "portrait-primary",
|
||||
.portrait_secondary => "portrait-secondary",
|
||||
.landscape_primary => "landscape-primary",
|
||||
.landscape_secondary => "landscape-secondary",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const ScreenOrientation = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
angle: u32 = 0,
|
||||
type: ScreenOrientationType,
|
||||
|
||||
pub fn get_angle(self: *const ScreenOrientation) u32 {
|
||||
return self.angle;
|
||||
}
|
||||
|
||||
pub fn get_type(self: *const ScreenOrientation) []const u8 {
|
||||
return self.type.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Screen" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let screen = window.screen", "undefined" },
|
||||
.{ "screen.width === 1920", "true" },
|
||||
.{ "screen.height === 1080", "true" },
|
||||
.{ "let orientation = screen.orientation", "undefined" },
|
||||
.{ "orientation.angle === 0", "true" },
|
||||
.{ "orientation.type === \"landscape-primary\"", "true" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
pub const HTMLSelectElement = struct {
|
||||
pub const Self = parser.Select;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
|
||||
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, page: *Page) !i32 {
|
||||
const state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
|
||||
const selected_index = try parser.selectGetSelectedIndex(select);
|
||||
|
||||
// See the explicit_index_set field documentation
|
||||
if (!state.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, page: *Page) !void {
|
||||
var state = try page.getOrCreateNodeState(@alignCast(@ptrCast(select)));
|
||||
state.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" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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 Element = @import("../dom/element.zig").Element;
|
||||
|
||||
// Support for SVGElements is very limited, this is a dummy implementation.
|
||||
// This is here no to be able to support `element instanceof SVGElement;` in JavaScript.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/SVGElement
|
||||
pub const SVGElement = struct {
|
||||
// Currently the prototype chain is not implemented (will not be returned by toInterface())
|
||||
// For that we need parser.SvgElement and the derived types with tags in the v-table.
|
||||
pub const prototype = *Element;
|
||||
// While this is a Node, could consider not exposing the subtype untill we have
|
||||
// a Self type to cast to.
|
||||
pub const subtype = .node;
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.SVGElement" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "'AString' instanceof SVGElement", "false" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Function = @import("../env.zig").Function;
|
||||
const Page = @import("../page.zig").Page;
|
||||
const Loop = @import("../../runtime/loop.zig").Loop;
|
||||
|
||||
const Navigator = @import("navigator.zig").Navigator;
|
||||
const History = @import("history.zig").History;
|
||||
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("../dom/performance.zig").Performance;
|
||||
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
const Screen = @import("screen.zig").Screen;
|
||||
const Css = @import("../css/css.zig").Css;
|
||||
|
||||
const storage = @import("../storage/storage.zig");
|
||||
|
||||
// https://dom.spec.whatwg.org/#interface-window-extensions
|
||||
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#window
|
||||
pub const Window = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
document: *parser.DocumentHTML,
|
||||
target: []const u8 = "",
|
||||
history: History = .{},
|
||||
location: Location = .{},
|
||||
storage_shelf: ?*storage.Shelf = null,
|
||||
|
||||
// counter for having unique timer ids
|
||||
timer_id: u30 = 0,
|
||||
timers: std.AutoHashMapUnmanaged(u32, *TimerCallback) = .{},
|
||||
|
||||
crypto: Crypto = .{},
|
||||
console: Console = .{},
|
||||
navigator: Navigator = .{},
|
||||
performance: Performance,
|
||||
screen: Screen = .{},
|
||||
css: Css = .{},
|
||||
|
||||
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");
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn setStorageShelf(self: *Window, shelf: *storage.Shelf) void {
|
||||
self.storage_shelf = shelf;
|
||||
}
|
||||
|
||||
pub fn get_window(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn get_navigator(self: *Window) *Navigator {
|
||||
return &self.navigator;
|
||||
}
|
||||
|
||||
pub fn get_location(self: *Window) *Location {
|
||||
return &self.location;
|
||||
}
|
||||
|
||||
pub fn set_location(_: *const Window, url: []const u8, page: *Page) !void {
|
||||
return page.navigateFromWebAPI(url, .{ .reason = .script });
|
||||
}
|
||||
|
||||
pub fn get_console(self: *Window) *Console {
|
||||
return &self.console;
|
||||
}
|
||||
|
||||
pub fn get_crypto(self: *Window) *Crypto {
|
||||
return &self.crypto;
|
||||
}
|
||||
|
||||
pub fn get_self(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn get_parent(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
// TODO: frames
|
||||
pub fn get_top(self: *Window) *Window {
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn get_document(self: *Window) ?*parser.DocumentHTML {
|
||||
return self.document;
|
||||
}
|
||||
|
||||
pub fn get_history(self: *Window) *History {
|
||||
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, page: *Page) u32 {
|
||||
// We do not have scrollbars or padding so this is the same as Element.clientHeight
|
||||
return page.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, page: *Page) u32 {
|
||||
// We do not have scrollbars or padding so this is the same as Element.clientWidth
|
||||
return page.renderer.width();
|
||||
}
|
||||
|
||||
pub fn get_name(self: *Window) []const u8 {
|
||||
return self.target;
|
||||
}
|
||||
|
||||
pub fn get_localStorage(self: *Window) !*storage.Bottle {
|
||||
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
|
||||
return &self.storage_shelf.?.bucket.local;
|
||||
}
|
||||
|
||||
pub fn get_sessionStorage(self: *Window) !*storage.Bottle {
|
||||
if (self.storage_shelf == null) return parser.DOMError.NotSupported;
|
||||
return &self.storage_shelf.?.bucket.session;
|
||||
}
|
||||
|
||||
pub fn get_performance(self: *Window) *Performance {
|
||||
return &self.performance;
|
||||
}
|
||||
|
||||
pub fn get_screen(self: *Window) *Screen {
|
||||
return &self.screen;
|
||||
}
|
||||
|
||||
pub fn get_CSS(self: *Window) *Css {
|
||||
return &self.css;
|
||||
}
|
||||
|
||||
pub fn _requestAnimationFrame(self: *Window, cbk: Function, page: *Page) !u32 {
|
||||
return self.createTimeout(cbk, 5, page, .{ .animation_frame = true });
|
||||
}
|
||||
|
||||
pub fn _cancelAnimationFrame(self: *Window, id: u32, page: *Page) !void {
|
||||
const kv = self.timers.fetchRemove(id) orelse return;
|
||||
return page.loop.cancel(kv.value.loop_id);
|
||||
}
|
||||
|
||||
// TODO handle callback arguments.
|
||||
pub fn _setTimeout(self: *Window, cbk: Function, delay: ?u32, page: *Page) !u32 {
|
||||
return self.createTimeout(cbk, delay, page, .{});
|
||||
}
|
||||
|
||||
// TODO handle callback arguments.
|
||||
pub fn _setInterval(self: *Window, cbk: Function, delay: ?u32, page: *Page) !u32 {
|
||||
return self.createTimeout(cbk, delay, page, .{ .repeat = true });
|
||||
}
|
||||
|
||||
pub fn _clearTimeout(self: *Window, id: u32, page: *Page) !void {
|
||||
const kv = self.timers.fetchRemove(id) orelse return;
|
||||
return page.loop.cancel(kv.value.loop_id);
|
||||
}
|
||||
|
||||
pub fn _clearInterval(self: *Window, id: u32, page: *Page) !void {
|
||||
const kv = self.timers.fetchRemove(id) orelse return;
|
||||
return page.loop.cancel(kv.value.loop_id);
|
||||
}
|
||||
|
||||
pub fn _matchMedia(_: *const Window, media: []const u8, page: *Page) !MediaQueryList {
|
||||
return .{
|
||||
.matches = false, // TODO?
|
||||
.media = try page.arena.dupe(u8, media),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _btoa(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
const out = try page.call_arena.alloc(u8, Encoder.calcSize(value.len));
|
||||
return Encoder.encode(out, value);
|
||||
}
|
||||
|
||||
pub fn _atob(_: *const Window, value: []const u8, page: *Page) ![]const u8 {
|
||||
const Decoder = std.base64.standard.Decoder;
|
||||
const size = Decoder.calcSizeForSlice(value) catch return error.InvalidCharacterError;
|
||||
|
||||
const out = try page.call_arena.alloc(u8, size);
|
||||
Decoder.decode(out, value) catch return error.InvalidCharacterError;
|
||||
return out;
|
||||
}
|
||||
|
||||
const CreateTimeoutOpts = struct {
|
||||
repeat: bool = false,
|
||||
animation_frame: bool = false,
|
||||
};
|
||||
fn createTimeout(self: *Window, cbk: Function, delay_: ?u32, page: *Page, comptime opts: CreateTimeoutOpts) !u32 {
|
||||
const delay = delay_ orelse 0;
|
||||
if (delay > 5000) {
|
||||
log.warn(.user_script, "long timeout ignored", .{ .delay = delay, .interval = opts.repeat });
|
||||
// self.timer_id is u30, so the largest value we can generate is
|
||||
// 1_073_741_824. Returning 2_000_000_000 makes sure that clients
|
||||
// can call cancelTimer/cancelInterval without breaking anything.
|
||||
return 2_000_000_000;
|
||||
}
|
||||
|
||||
if (self.timers.count() > 512) {
|
||||
return error.TooManyTimeout;
|
||||
}
|
||||
const timer_id = self.timer_id +% 1;
|
||||
self.timer_id = timer_id;
|
||||
|
||||
const arena = page.arena;
|
||||
|
||||
const gop = try self.timers.getOrPut(arena, timer_id);
|
||||
if (gop.found_existing) {
|
||||
// this can only happen if we've created 2^31 timeouts.
|
||||
return error.TooManyTimeout;
|
||||
}
|
||||
errdefer _ = self.timers.remove(timer_id);
|
||||
|
||||
const delay_ms: u63 = @as(u63, delay) * std.time.ns_per_ms;
|
||||
const callback = try arena.create(TimerCallback);
|
||||
|
||||
callback.* = .{
|
||||
.cbk = cbk,
|
||||
.loop_id = 0, // we're going to set this to a real value shortly
|
||||
.window = self,
|
||||
.timer_id = timer_id,
|
||||
.node = .{ .func = TimerCallback.run },
|
||||
.repeat = if (opts.repeat) delay_ms else null,
|
||||
.animation_frame = opts.animation_frame,
|
||||
};
|
||||
callback.loop_id = try page.loop.timeout(delay_ms, &callback.node);
|
||||
|
||||
gop.value_ptr.* = callback;
|
||||
return timer_id;
|
||||
}
|
||||
|
||||
// TODO: getComputedStyle should return a read-only CSSStyleDeclaration.
|
||||
// We currently don't have a read-only one, so we return a new instance on
|
||||
// each call.
|
||||
pub fn _getComputedStyle(_: *const Window, element: *parser.Element, pseudo_element: ?[]const u8) !CSSStyleDeclaration {
|
||||
_ = element;
|
||||
_ = pseudo_element;
|
||||
return .empty;
|
||||
}
|
||||
|
||||
const ScrollToOpts = union(enum) {
|
||||
x: i32,
|
||||
opts: Opts,
|
||||
|
||||
const Opts = struct {
|
||||
top: i32,
|
||||
left: i32,
|
||||
behavior: []const u8,
|
||||
};
|
||||
};
|
||||
pub fn _scrollTo(self: *Window, opts: ScrollToOpts, y: ?u32) !void {
|
||||
_ = opts;
|
||||
_ = y;
|
||||
|
||||
{
|
||||
const scroll_event = try parser.eventCreate();
|
||||
defer parser.eventDestroy(scroll_event);
|
||||
|
||||
try parser.eventInit(scroll_event, "scroll", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(Window, self),
|
||||
scroll_event,
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const scroll_end = try parser.eventCreate();
|
||||
defer parser.eventDestroy(scroll_end);
|
||||
|
||||
try parser.eventInit(scroll_end, "scrollend", .{});
|
||||
_ = try parser.eventTargetDispatchEvent(
|
||||
parser.toEventTarget(parser.DocumentHTML, self.document),
|
||||
scroll_end,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TimerCallback = struct {
|
||||
// the internal loop id, need it when cancelling
|
||||
loop_id: usize,
|
||||
|
||||
// the id of our timer (windows.timers key)
|
||||
timer_id: u31,
|
||||
|
||||
// The JavaScript callback to execute
|
||||
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
|
||||
node: Loop.CallbackNode = undefined,
|
||||
|
||||
// if the event should be repeated
|
||||
repeat: ?u63 = null,
|
||||
|
||||
animation_frame: bool = false,
|
||||
|
||||
window: *Window,
|
||||
|
||||
fn run(node: *Loop.CallbackNode, repeat_delay: *?u63) void {
|
||||
const self: *TimerCallback = @fieldParentPtr("node", node);
|
||||
|
||||
var result: Function.Result = undefined;
|
||||
|
||||
var call: anyerror!void = undefined;
|
||||
if (self.animation_frame) {
|
||||
call = self.cbk.tryCall(void, .{self.window.performance._now()}, &result);
|
||||
} else {
|
||||
call = self.cbk.tryCall(void, .{}, &result);
|
||||
}
|
||||
|
||||
call catch {
|
||||
log.debug(.user_script, "callback error", .{
|
||||
.err = result.exception,
|
||||
.stack = result.stack,
|
||||
.source = "window timeout",
|
||||
});
|
||||
};
|
||||
|
||||
if (self.repeat) |r| {
|
||||
// setInterval
|
||||
repeat_delay.* = r;
|
||||
return;
|
||||
}
|
||||
|
||||
// setTimeout
|
||||
_ = 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();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "window.parent === window", "true" },
|
||||
.{ "window.top === window", "true" },
|
||||
}, .{});
|
||||
|
||||
// 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 = 0;
|
||||
\\ function step(timestamp) {
|
||||
\\ start = timestamp;
|
||||
\\ }
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
|
||||
.{ " start > 0", "true" },
|
||||
}, .{});
|
||||
|
||||
// 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" },
|
||||
}, .{});
|
||||
|
||||
// cancelAnimationFrame should be able to cancel a request with the given id
|
||||
try runner.testCases(&.{
|
||||
.{ "let longCall = false;", null },
|
||||
.{ "window.setTimeout(() => {longCall = true}, 5001);", null },
|
||||
.{ "longCall;", "false" },
|
||||
}, .{});
|
||||
|
||||
// window event target
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ let called = false;
|
||||
\\ window.addEventListener("ready", (e) => {
|
||||
\\ called = (e.currentTarget == window);
|
||||
\\ }, {capture: false, once: false});
|
||||
\\ const evt = new Event("ready", { bubbles: true, cancelable: false });
|
||||
\\ window.dispatchEvent(evt);
|
||||
\\ called;
|
||||
,
|
||||
"true",
|
||||
},
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const b64 = btoa('https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder')", "undefined" },
|
||||
.{ "b64", "aHR0cHM6Ly96aWdsYW5nLm9yZy9kb2N1bWVudGF0aW9uL21hc3Rlci9zdGQvI3N0ZC5iYXNlNjQuQmFzZTY0RGVjb2Rlcg==" },
|
||||
.{ "const str = atob(b64)", "undefined" },
|
||||
.{ "str", "https://ziglang.org/documentation/master/std/#std.base64.Base64Decoder" },
|
||||
.{ "try { atob('b') } catch (e) { e } ", "Error: InvalidCharacterError" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let scroll = false; let scrolend = false", null },
|
||||
.{ "window.addEventListener('scroll', () => {scroll = true});", null },
|
||||
.{ "document.addEventListener('scrollend', () => {scrollend = true});", null },
|
||||
.{ "window.scrollTo(0)", null },
|
||||
.{ "scroll", "true" },
|
||||
.{ "scrollend", "true" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
pub const Interfaces = .{
|
||||
U32Iterator,
|
||||
};
|
||||
|
||||
pub const U32Iterator = struct {
|
||||
length: u32,
|
||||
index: u32 = 0,
|
||||
|
||||
pub const Return = struct {
|
||||
value: u32,
|
||||
done: bool,
|
||||
};
|
||||
|
||||
pub fn _next(self: *U32Iterator) Return {
|
||||
const i = self.index;
|
||||
if (i >= self.length) {
|
||||
return .{
|
||||
.value = 0,
|
||||
.done = true,
|
||||
};
|
||||
}
|
||||
|
||||
self.index = i + 1;
|
||||
return .{
|
||||
.value = i,
|
||||
.done = false,
|
||||
};
|
||||
}
|
||||
|
||||
// Iterators should be iterable. There's a [JS] example on MDN that
|
||||
// suggests this is the correct approach:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol
|
||||
pub fn _symbol_iterator(self: *U32Iterator) *U32Iterator {
|
||||
return self;
|
||||
}
|
||||
};
|
||||
|
||||
// A wrapper around an iterator that emits an Iterable result
|
||||
// An iterable has a next() which emits a {done: bool, value: T} result
|
||||
pub fn Iterable(comptime T: type, comptime JsName: []const u8) type {
|
||||
// The inner iterator's return type.
|
||||
// Maybe an error union.
|
||||
// Definitely an optional
|
||||
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
|
||||
const CanError = @typeInfo(RawValue) == .error_union;
|
||||
|
||||
const Value = blk: {
|
||||
// Unwrap the RawValue
|
||||
var V = RawValue;
|
||||
if (CanError) {
|
||||
V = @typeInfo(V).error_union.payload;
|
||||
}
|
||||
break :blk @typeInfo(V).optional.child;
|
||||
};
|
||||
|
||||
const Result = struct {
|
||||
done: bool,
|
||||
// todo, technically, we should return undefined when done = true
|
||||
// or even omit the value;
|
||||
value: ?Value,
|
||||
};
|
||||
|
||||
const ReturnType = if (CanError) T.Error!Result else Result;
|
||||
|
||||
return struct {
|
||||
// the inner value iterator
|
||||
inner: T,
|
||||
|
||||
// Generics don't generate clean names. Can't just take the resulting
|
||||
// type name and use that as a the JS class name. So we always ask for
|
||||
// an explicit JS class name
|
||||
pub const js_name = JsName;
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(inner: T) Self {
|
||||
return .{ .inner = inner };
|
||||
}
|
||||
|
||||
pub fn _next(self: *Self) ReturnType {
|
||||
const value = if (comptime CanError) try self.inner._next() else self.inner._next();
|
||||
return .{ .done = value == null, .value = value };
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *Self) *Self {
|
||||
return self;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// A wrapper around an iterator that emits integer/index keyed entries.
|
||||
pub fn NumericEntries(comptime T: type, comptime JsName: []const u8) type {
|
||||
// The inner iterator's return type.
|
||||
// Maybe an error union.
|
||||
// Definitely an optional
|
||||
const RawValue = @typeInfo(@TypeOf(T._next)).@"fn".return_type.?;
|
||||
const CanError = @typeInfo(RawValue) == .error_union;
|
||||
|
||||
const Value = blk: {
|
||||
// Unwrap the RawValue
|
||||
var V = RawValue;
|
||||
if (CanError) {
|
||||
V = @typeInfo(V).error_union.payload;
|
||||
}
|
||||
break :blk @typeInfo(V).optional.child;
|
||||
};
|
||||
|
||||
const ReturnType = if (CanError) T.Error!?struct { u32, Value } else ?struct { u32, Value };
|
||||
|
||||
// Avoid ambiguity. We want to expose a NumericEntries(T).Iterable, so we
|
||||
// need a declartion inside here for an "Iterable", but that will conflict
|
||||
// with the above Iterable generic function we have.
|
||||
const BaseIterable = Iterable;
|
||||
|
||||
return struct {
|
||||
// the inner value iterator
|
||||
inner: T,
|
||||
index: u32,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
// Generics don't generate clean names. Can't just take the resulting
|
||||
// type name and use that as a the JS class name. So we always ask for
|
||||
// an explicit JS class name
|
||||
pub const js_name = JsName;
|
||||
|
||||
// re-exposed for when/if we compose this type into an Iterable
|
||||
pub const Error = T.Error;
|
||||
|
||||
// This iterator as an iterable
|
||||
pub const Iterable = BaseIterable(Self, JsName ++ "Iterable");
|
||||
|
||||
pub fn init(inner: T) Self {
|
||||
return .{ .inner = inner, .index = 0 };
|
||||
}
|
||||
|
||||
pub fn _next(self: *Self) ReturnType {
|
||||
const value_ = if (comptime CanError) try self.inner._next() else self.inner._next();
|
||||
const value = value_ orelse return null;
|
||||
|
||||
const index = self.index;
|
||||
self.index = index + 1;
|
||||
return .{ index, value };
|
||||
}
|
||||
|
||||
// make the iterator, iterable
|
||||
pub fn _symbol_iterator(self: *Self) Self.Iterable {
|
||||
return Self.Iterable.init(self.*);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "U32Iterator" {
|
||||
{
|
||||
var it = U32Iterator{ .length = 0 };
|
||||
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
|
||||
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
|
||||
}
|
||||
|
||||
{
|
||||
var it = U32Iterator{ .length = 3 };
|
||||
try testing.expectEqual(.{ .value = 0, .done = false }, it._next());
|
||||
try testing.expectEqual(.{ .value = 1, .done = false }, it._next());
|
||||
try testing.expectEqual(.{ .value = 2, .done = false }, it._next());
|
||||
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
|
||||
try testing.expectEqual(.{ .value = 0, .done = true }, it._next());
|
||||
}
|
||||
}
|
||||
|
||||
test "NumericEntries" {
|
||||
const it = DummyIterator{};
|
||||
var entries = NumericEntries(DummyIterator, "DummyIterator").init(it);
|
||||
|
||||
const v1 = entries._next().?;
|
||||
try testing.expectEqual(0, v1.@"0");
|
||||
try testing.expectEqual("it's", v1.@"1");
|
||||
|
||||
const v2 = entries._next().?;
|
||||
try testing.expectEqual(1, v2.@"0");
|
||||
try testing.expectEqual("over", v2.@"1");
|
||||
|
||||
const v3 = entries._next().?;
|
||||
try testing.expectEqual(2, v3.@"0");
|
||||
try testing.expectEqual("9000!!", v3.@"1");
|
||||
|
||||
try testing.expectEqual(null, entries._next());
|
||||
try testing.expectEqual(null, entries._next());
|
||||
try testing.expectEqual(null, entries._next());
|
||||
}
|
||||
|
||||
test "Iterable" {
|
||||
const it = DummyIterator{};
|
||||
var entries = Iterable(DummyIterator, "DummyIterator").init(it);
|
||||
|
||||
const v1 = entries._next();
|
||||
try testing.expectEqual(false, v1.done);
|
||||
try testing.expectEqual("it's", v1.value.?);
|
||||
|
||||
const v2 = entries._next();
|
||||
try testing.expectEqual(false, v2.done);
|
||||
try testing.expectEqual("over", v2.value.?);
|
||||
|
||||
const v3 = entries._next();
|
||||
try testing.expectEqual(false, v3.done);
|
||||
try testing.expectEqual("9000!!", v3.value.?);
|
||||
|
||||
try testing.expectEqual(true, entries._next().done);
|
||||
try testing.expectEqual(true, entries._next().done);
|
||||
try testing.expectEqual(true, entries._next().done);
|
||||
}
|
||||
|
||||
const DummyIterator = struct {
|
||||
index: u32 = 0,
|
||||
|
||||
pub fn _next(self: *DummyIterator) ?[]const u8 {
|
||||
const index = self.index;
|
||||
self.index = index + 1;
|
||||
return switch (index) {
|
||||
0 => "it's",
|
||||
1 => "over",
|
||||
2 => "9000!!",
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,284 +0,0 @@
|
||||
// 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;
|
||||
|
||||
// Used by FormDAta and URLSearchParams.
|
||||
//
|
||||
// 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).
|
||||
pub const List = struct {
|
||||
entries: std.ArrayListUnmanaged(KeyValue) = .{},
|
||||
|
||||
pub fn init(entries: std.ArrayListUnmanaged(KeyValue)) List {
|
||||
return .{ .entries = entries };
|
||||
}
|
||||
|
||||
pub fn clone(self: *const List, arena: Allocator) !List {
|
||||
const entries = self.entries.items;
|
||||
|
||||
var c: std.ArrayListUnmanaged(KeyValue) = .{};
|
||||
try c.ensureTotalCapacity(arena, entries.len);
|
||||
for (entries) |kv| {
|
||||
c.appendAssumeCapacity(kv);
|
||||
}
|
||||
|
||||
return .{ .entries = c };
|
||||
}
|
||||
|
||||
pub fn fromOwnedSlice(entries: []KeyValue) List {
|
||||
return .{
|
||||
.entries = std.ArrayListUnmanaged(KeyValue).fromOwnedSlice(entries),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn count(self: *const List) usize {
|
||||
return self.entries.items.len;
|
||||
}
|
||||
|
||||
pub fn get(self: *const List, key: []const u8) ?[]const u8 {
|
||||
const result = self.find(key) orelse return null;
|
||||
return result.entry.value;
|
||||
}
|
||||
|
||||
pub fn getAll(self: *const List, arena: Allocator, key: []const u8) ![]const []const u8 {
|
||||
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 List, key: []const u8) bool {
|
||||
return self.find(key) != null;
|
||||
}
|
||||
|
||||
pub fn set(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void {
|
||||
self.delete(key);
|
||||
return self.append(arena, key, value);
|
||||
}
|
||||
|
||||
pub fn append(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void {
|
||||
return self.appendOwned(arena, try arena.dupe(u8, key), try arena.dupe(u8, value));
|
||||
}
|
||||
|
||||
pub fn appendOwned(self: *List, arena: Allocator, key: []const u8, value: []const u8) !void {
|
||||
return self.entries.append(arena, .{
|
||||
.key = key,
|
||||
.value = value,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn appendOwnedAssumeCapacity(self: *List, key: []const u8, value: []const u8) void {
|
||||
self.entries.appendAssumeCapacity(.{
|
||||
.key = key,
|
||||
.value = value,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn delete(self: *List, 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 deleteKeyValue(self: *List, key: []const u8, value: []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) and std.mem.eql(u8, value, entry.value)) {
|
||||
_ = self.entries.swapRemove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyIterator(self: *const List) KeyIterator {
|
||||
return .{ .entries = &self.entries };
|
||||
}
|
||||
|
||||
pub fn valueIterator(self: *const List) ValueIterator {
|
||||
return .{ .entries = &self.entries };
|
||||
}
|
||||
|
||||
pub fn entryIterator(self: *const List) EntryIterator {
|
||||
return .{ .entries = &self.entries };
|
||||
}
|
||||
|
||||
pub fn ensureTotalCapacity(self: *List, arena: Allocator, len: usize) !void {
|
||||
return self.entries.ensureTotalCapacity(arena, len);
|
||||
}
|
||||
|
||||
const FindResult = struct {
|
||||
index: usize,
|
||||
entry: KeyValue,
|
||||
};
|
||||
|
||||
fn find(self: *const List, 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;
|
||||
}
|
||||
};
|
||||
|
||||
pub const KeyValue = struct {
|
||||
key: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
pub const KeyIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(KeyValue),
|
||||
|
||||
pub fn _next(self: *KeyIterator) ?[]const u8 {
|
||||
const entries = self.entries.items;
|
||||
|
||||
const index = self.index;
|
||||
if (index == entries.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
return entries[index].key;
|
||||
}
|
||||
};
|
||||
|
||||
pub const ValueIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(KeyValue),
|
||||
|
||||
pub fn _next(self: *ValueIterator) ?[]const u8 {
|
||||
const entries = self.entries.items;
|
||||
|
||||
const index = self.index;
|
||||
if (index == entries.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
return entries[index].value;
|
||||
}
|
||||
};
|
||||
|
||||
pub const EntryIterator = struct {
|
||||
index: usize = 0,
|
||||
entries: *const std.ArrayListUnmanaged(KeyValue),
|
||||
|
||||
pub fn _next(self: *EntryIterator) ?struct { []const u8, []const u8 } {
|
||||
const entries = self.entries.items;
|
||||
|
||||
const index = self.index;
|
||||
if (index == entries.len) {
|
||||
return null;
|
||||
}
|
||||
self.index += 1;
|
||||
const entry = entries[index];
|
||||
return .{ entry.key, entry.value };
|
||||
}
|
||||
};
|
||||
|
||||
const URLEncodeMode = enum {
|
||||
form,
|
||||
query,
|
||||
};
|
||||
|
||||
pub fn urlEncode(list: List, mode: URLEncodeMode, writer: anytype) !void {
|
||||
const entries = list.entries.items;
|
||||
if (entries.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try urlEncodeEntry(entries[0], mode, writer);
|
||||
for (entries[1..]) |entry| {
|
||||
try writer.writeByte('&');
|
||||
try urlEncodeEntry(entry, mode, writer);
|
||||
}
|
||||
}
|
||||
|
||||
fn urlEncodeEntry(entry: KeyValue, mode: URLEncodeMode, writer: anytype) !void {
|
||||
try urlEncodeValue(entry.key, mode, writer);
|
||||
|
||||
// for a form, for an empty value, we'll do "spice="
|
||||
// but for a query, we do "spice"
|
||||
if (mode == .query and entry.value.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try writer.writeByte('=');
|
||||
try urlEncodeValue(entry.value, mode, writer);
|
||||
}
|
||||
|
||||
fn urlEncodeValue(value: []const u8, mode: URLEncodeMode, writer: anytype) !void {
|
||||
if (!urlEncodeShouldEscape(value, mode)) {
|
||||
return writer.writeAll(value);
|
||||
}
|
||||
|
||||
for (value) |b| {
|
||||
if (urlEncodeUnreserved(b, mode)) {
|
||||
try writer.writeByte(b);
|
||||
} else if (b == ' ' and mode == .form) {
|
||||
// for form submission, space should be encoded as '+', not '%20'
|
||||
try writer.writeByte('+');
|
||||
} else {
|
||||
try writer.print("%{X:0>2}", .{b});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn urlEncodeShouldEscape(value: []const u8, mode: URLEncodeMode) bool {
|
||||
for (value) |b| {
|
||||
if (!urlEncodeUnreserved(b, mode)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn urlEncodeUnreserved(b: u8, mode: URLEncodeMode) bool {
|
||||
return switch (b) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => true,
|
||||
'~' => mode == .query,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
91
src/browser/loader.zig
Normal file
91
src/browser/loader.zig
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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 Client = @import("../http/Client.zig");
|
||||
|
||||
const user_agent = @import("browser.zig").user_agent;
|
||||
|
||||
pub const Loader = struct {
|
||||
client: Client,
|
||||
// use 64KB for headers buffer size.
|
||||
server_header_buffer: [1024 * 64]u8 = undefined,
|
||||
|
||||
pub const Response = struct {
|
||||
alloc: std.mem.Allocator,
|
||||
req: *Client.Request,
|
||||
|
||||
pub fn deinit(self: *Response) void {
|
||||
self.req.deinit();
|
||||
self.alloc.destroy(self.req);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn init(alloc: std.mem.Allocator) Loader {
|
||||
return Loader{
|
||||
.client = Client{
|
||||
.allocator = alloc,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Loader) void {
|
||||
self.client.deinit();
|
||||
}
|
||||
|
||||
// see
|
||||
// https://ziglang.org/documentation/master/std/#A;std:http.Client.fetch
|
||||
// for reference.
|
||||
// The caller is responsible for calling `deinit()` on the `Response`.
|
||||
pub fn get(self: *Loader, alloc: std.mem.Allocator, uri: std.Uri) !Response {
|
||||
var resp = Response{
|
||||
.alloc = alloc,
|
||||
.req = try alloc.create(Client.Request),
|
||||
};
|
||||
errdefer alloc.destroy(resp.req);
|
||||
|
||||
resp.req.* = try self.client.open(.GET, uri, .{
|
||||
.headers = .{
|
||||
.user_agent = .{ .override = user_agent },
|
||||
},
|
||||
.extra_headers = &.{
|
||||
.{ .name = "Accept", .value = "*/*" },
|
||||
.{ .name = "Accept-Language", .value = "en-US,en;q=0.5" },
|
||||
},
|
||||
.server_header_buffer = &self.server_header_buffer,
|
||||
});
|
||||
errdefer resp.req.deinit();
|
||||
|
||||
try resp.req.send();
|
||||
try resp.req.finish();
|
||||
try resp.req.wait();
|
||||
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
|
||||
test "basic url get" {
|
||||
const alloc = std.testing.allocator;
|
||||
var loader = Loader.init(alloc);
|
||||
defer loader.deinit();
|
||||
|
||||
var result = try loader.get(alloc, "https://en.wikipedia.org/wiki/Main_Page");
|
||||
defer result.deinit();
|
||||
|
||||
try std.testing.expect(result.req.response.status == std.http.Status.ok);
|
||||
}
|
||||
@@ -17,504 +17,141 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const testing = std.testing;
|
||||
|
||||
pub const Mime = struct {
|
||||
content_type: ContentType,
|
||||
params: []const u8 = "",
|
||||
charset: ?[]const u8 = null,
|
||||
const strparser = @import("../str/parser.zig");
|
||||
const Reader = strparser.Reader;
|
||||
const trim = strparser.trim;
|
||||
|
||||
pub const unknown = Mime{
|
||||
.params = "",
|
||||
.charset = "",
|
||||
.content_type = .{ .unknown = {} },
|
||||
};
|
||||
const Self = @This();
|
||||
|
||||
pub const ContentTypeEnum = enum {
|
||||
text_xml,
|
||||
text_html,
|
||||
text_javascript,
|
||||
text_plain,
|
||||
text_css,
|
||||
application_json,
|
||||
unknown,
|
||||
other,
|
||||
};
|
||||
|
||||
pub const ContentType = union(ContentTypeEnum) {
|
||||
text_xml: void,
|
||||
text_html: void,
|
||||
text_javascript: void,
|
||||
text_plain: void,
|
||||
text_css: void,
|
||||
application_json: void,
|
||||
unknown: void,
|
||||
other: struct { type: []const u8, sub_type: []const u8 },
|
||||
};
|
||||
|
||||
pub fn parse(arena: Allocator, input: []u8) !Mime {
|
||||
if (input.len > 255) {
|
||||
return error.TooBig;
|
||||
}
|
||||
|
||||
// Zig's trim API is broken. The return type is always `[]const u8`,
|
||||
// even if the input type is `[]u8`. @constCast is safe here.
|
||||
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
|
||||
_ = std.ascii.lowerString(normalized, normalized);
|
||||
|
||||
const content_type, const type_len = try parseContentType(normalized);
|
||||
if (type_len >= normalized.len) {
|
||||
return .{ .content_type = content_type };
|
||||
}
|
||||
|
||||
const params = trimLeft(normalized[type_len..]);
|
||||
|
||||
var charset: ?[]const u8 = null;
|
||||
|
||||
var it = std.mem.splitScalar(u8, params, ';');
|
||||
while (it.next()) |attr| {
|
||||
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse return error.Invalid;
|
||||
const name = trimLeft(attr[0..i]);
|
||||
|
||||
const value = trimRight(attr[i + 1 ..]);
|
||||
if (value.len == 0) {
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
const attribute_name = std.meta.stringToEnum(enum {
|
||||
charset,
|
||||
}, name) orelse continue;
|
||||
|
||||
switch (attribute_name) {
|
||||
.charset => charset = try parseAttributeValue(arena, value),
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.params = params,
|
||||
.charset = charset,
|
||||
.content_type = content_type,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn sniff(body: []const u8) ?Mime {
|
||||
// 0x0C is form feed
|
||||
const content = std.mem.trimLeft(u8, body, &.{ ' ', '\t', '\n', '\r', 0x0C });
|
||||
if (content.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (content[0] != '<') {
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xEF, 0xBB, 0xBF })) {
|
||||
// UTF-8 BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
|
||||
// UTF-16 big-endian BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
|
||||
// UTF-16 little-endian BOM
|
||||
return .{ .content_type = .{ .text_plain = {} } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The longest prefix we have is "<!DOCTYPE HTML ", 15 bytes. If we're
|
||||
// here, we already know content[0] == '<', so we can skip that. So 14
|
||||
// bytes.
|
||||
|
||||
// +1 because we don't need the leading '<'
|
||||
var buf: [14]u8 = undefined;
|
||||
|
||||
const stripped = content[1..];
|
||||
const prefix_len = @min(stripped.len, buf.len);
|
||||
const prefix = std.ascii.lowerString(&buf, stripped[0..prefix_len]);
|
||||
|
||||
// we already know it starts with a <
|
||||
const known_prefixes = [_]struct { []const u8, ContentType }{
|
||||
.{ "!doctype html", .{ .text_html = {} } },
|
||||
.{ "html", .{ .text_html = {} } },
|
||||
.{ "script", .{ .text_html = {} } },
|
||||
.{ "iframe", .{ .text_html = {} } },
|
||||
.{ "h1", .{ .text_html = {} } },
|
||||
.{ "div", .{ .text_html = {} } },
|
||||
.{ "font", .{ .text_html = {} } },
|
||||
.{ "table", .{ .text_html = {} } },
|
||||
.{ "a", .{ .text_html = {} } },
|
||||
.{ "style", .{ .text_html = {} } },
|
||||
.{ "title", .{ .text_html = {} } },
|
||||
.{ "b", .{ .text_html = {} } },
|
||||
.{ "body", .{ .text_html = {} } },
|
||||
.{ "br", .{ .text_html = {} } },
|
||||
.{ "p", .{ .text_html = {} } },
|
||||
.{ "!--", .{ .text_html = {} } },
|
||||
.{ "xml", .{ .text_xml = {} } },
|
||||
};
|
||||
inline for (known_prefixes) |kp| {
|
||||
const known_prefix = kp.@"0";
|
||||
if (std.mem.startsWith(u8, prefix, known_prefix) and prefix.len > known_prefix.len) {
|
||||
const next = prefix[known_prefix.len];
|
||||
// a "tag-terminating-byte"
|
||||
if (next == ' ' or next == '>') {
|
||||
return .{ .content_type = kp.@"1" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn isHTML(self: *const Mime) bool {
|
||||
return self.content_type == .text_html;
|
||||
}
|
||||
|
||||
// we expect value to be lowercase
|
||||
fn parseContentType(value: []const u8) !struct { ContentType, usize } {
|
||||
const end = std.mem.indexOfScalarPos(u8, value, 0, ';') orelse value.len;
|
||||
const type_name = trimRight(value[0..end]);
|
||||
const attribute_start = end + 1;
|
||||
|
||||
if (std.meta.stringToEnum(enum {
|
||||
@"text/xml",
|
||||
@"text/html",
|
||||
@"text/css",
|
||||
@"text/plain",
|
||||
|
||||
@"text/javascript",
|
||||
@"application/javascript",
|
||||
@"application/x-javascript",
|
||||
|
||||
@"application/json",
|
||||
}, type_name)) |known_type| {
|
||||
const ct: ContentType = switch (known_type) {
|
||||
.@"text/xml" => .{ .text_xml = {} },
|
||||
.@"text/html" => .{ .text_html = {} },
|
||||
.@"text/javascript", .@"application/javascript", .@"application/x-javascript" => .{ .text_javascript = {} },
|
||||
.@"text/plain" => .{ .text_plain = {} },
|
||||
.@"text/css" => .{ .text_css = {} },
|
||||
.@"application/json" => .{ .application_json = {} },
|
||||
};
|
||||
return .{ ct, attribute_start };
|
||||
}
|
||||
|
||||
const separator = std.mem.indexOfScalarPos(u8, type_name, 0, '/') orelse return error.Invalid;
|
||||
|
||||
const main_type = value[0..separator];
|
||||
const sub_type = trimRight(value[separator + 1 .. end]);
|
||||
|
||||
if (main_type.len == 0 or validType(main_type) == false) {
|
||||
return error.Invalid;
|
||||
}
|
||||
if (sub_type.len == 0 or validType(sub_type) == false) {
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
return .{ .{ .other = .{
|
||||
.type = main_type,
|
||||
.sub_type = sub_type,
|
||||
} }, attribute_start };
|
||||
}
|
||||
|
||||
const T_SPECIAL = blk: {
|
||||
var v = [_]bool{false} ** 256;
|
||||
for ("()<>@,;:\\\"/[]?=") |b| {
|
||||
v[b] = true;
|
||||
}
|
||||
break :blk v;
|
||||
};
|
||||
|
||||
fn parseAttributeValue(arena: Allocator, value: []const u8) ![]const u8 {
|
||||
if (value[0] != '"') {
|
||||
// almost certainly referenced from an http.Request which has its
|
||||
// own lifetime.
|
||||
return arena.dupe(u8, value);
|
||||
}
|
||||
|
||||
// 1 to skip the opening quote
|
||||
var value_pos: usize = 1;
|
||||
var unescaped_len: usize = 0;
|
||||
const last = value.len - 1;
|
||||
|
||||
while (value_pos < value.len) {
|
||||
switch (value[value_pos]) {
|
||||
'"' => break,
|
||||
'\\' => {
|
||||
if (value_pos == last) {
|
||||
return error.Invalid;
|
||||
}
|
||||
const next = value[value_pos + 1];
|
||||
if (T_SPECIAL[next] == false) {
|
||||
return error.Invalid;
|
||||
}
|
||||
value_pos += 2;
|
||||
},
|
||||
else => value_pos += 1,
|
||||
}
|
||||
unescaped_len += 1;
|
||||
}
|
||||
|
||||
if (unescaped_len == 0) {
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
value_pos = 1;
|
||||
const owned = try arena.alloc(u8, unescaped_len);
|
||||
for (0..unescaped_len) |i| {
|
||||
switch (value[value_pos]) {
|
||||
'"' => break,
|
||||
'\\' => {
|
||||
owned[i] = value[value_pos + 1];
|
||||
value_pos += 2;
|
||||
},
|
||||
else => |c| {
|
||||
owned[i] = c;
|
||||
value_pos += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
const VALID_CODEPOINTS = blk: {
|
||||
var v: [256]bool = undefined;
|
||||
for (0..256) |i| {
|
||||
v[i] = std.ascii.isAlphanumeric(i);
|
||||
}
|
||||
for ("!#$%&\\*+-.^'_`|~") |b| {
|
||||
v[b] = true;
|
||||
}
|
||||
break :blk v;
|
||||
};
|
||||
|
||||
fn validType(value: []const u8) bool {
|
||||
for (value) |b| {
|
||||
if (VALID_CODEPOINTS[b] == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn trimLeft(s: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, s, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
fn trimRight(s: []const u8) []const u8 {
|
||||
return std.mem.trimRight(u8, s, &std.ascii.whitespace);
|
||||
}
|
||||
const MimeError = error{
|
||||
Empty,
|
||||
TooBig,
|
||||
Invalid,
|
||||
InvalidChar,
|
||||
};
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "Mime: invalid " {
|
||||
defer testing.reset();
|
||||
mtype: []const u8,
|
||||
msubtype: []const u8,
|
||||
params: []const u8 = "",
|
||||
|
||||
const invalids = [_][]const u8{
|
||||
charset: ?[]const u8 = null,
|
||||
boundary: ?[]const u8 = null,
|
||||
|
||||
pub const Empty = Self{ .mtype = "", .msubtype = "" };
|
||||
pub const HTML = Self{ .mtype = "text", .msubtype = "html" };
|
||||
pub const Javascript = Self{ .mtype = "application", .msubtype = "javascript" };
|
||||
|
||||
// https://mimesniff.spec.whatwg.org/#http-token-code-point
|
||||
fn isHTTPCodePoint(c: u8) bool {
|
||||
return switch (c) {
|
||||
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^' => return true,
|
||||
'_', '`', '|', '~' => return true,
|
||||
else => std.ascii.isAlphanumeric(c),
|
||||
};
|
||||
}
|
||||
|
||||
fn valid(s: []const u8) bool {
|
||||
const ln = s.len;
|
||||
var i: usize = 0;
|
||||
while (i < ln) {
|
||||
if (!isHTTPCodePoint(s[i])) return false;
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// https://mimesniff.spec.whatwg.org/#parsing-a-mime-type
|
||||
pub fn parse(s: []const u8) Self.MimeError!Self {
|
||||
const ln = s.len;
|
||||
if (ln == 0) return MimeError.Empty;
|
||||
// limit input size
|
||||
if (ln > 255) return MimeError.TooBig;
|
||||
|
||||
var res = Self{ .mtype = "", .msubtype = "" };
|
||||
var r = Reader{ .s = s };
|
||||
|
||||
res.mtype = trim(r.until('/'));
|
||||
if (res.mtype.len == 0) return MimeError.Invalid;
|
||||
if (!valid(res.mtype)) return MimeError.InvalidChar;
|
||||
|
||||
if (!r.skip()) return MimeError.Invalid;
|
||||
res.msubtype = trim(r.until(';'));
|
||||
if (res.msubtype.len == 0) return MimeError.Invalid;
|
||||
if (!valid(res.msubtype)) return MimeError.InvalidChar;
|
||||
|
||||
if (!r.skip()) return res;
|
||||
res.params = trim(r.tail());
|
||||
if (res.params.len == 0) return MimeError.Invalid;
|
||||
|
||||
// parse well known parameters.
|
||||
// don't check invalid parameter format.
|
||||
var rp = Reader{ .s = res.params };
|
||||
while (true) {
|
||||
const name = trim(rp.until('='));
|
||||
if (!rp.skip()) return res;
|
||||
const value = trim(rp.until(';'));
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(name, "charset")) {
|
||||
res.charset = value;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase(name, "boundary")) {
|
||||
res.boundary = value;
|
||||
}
|
||||
|
||||
if (!rp.skip()) return res;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
test "parse valid" {
|
||||
for ([_][]const u8{
|
||||
"text/html",
|
||||
" \ttext/html",
|
||||
"text \t/html",
|
||||
"text/ \thtml",
|
||||
"text/html \t",
|
||||
}) |tc| {
|
||||
const m = try Self.parse(tc);
|
||||
try testing.expectEqualStrings("text", m.mtype);
|
||||
try testing.expectEqualStrings("html", m.msubtype);
|
||||
}
|
||||
const m2 = try Self.parse("text/javascript1.5");
|
||||
try testing.expectEqualStrings("text", m2.mtype);
|
||||
try testing.expectEqualStrings("javascript1.5", m2.msubtype);
|
||||
|
||||
const m3 = try Self.parse("text/html; charset=utf-8");
|
||||
try testing.expectEqualStrings("text", m3.mtype);
|
||||
try testing.expectEqualStrings("html", m3.msubtype);
|
||||
try testing.expectEqualStrings("charset=utf-8", m3.params);
|
||||
try testing.expectEqualStrings("utf-8", m3.charset.?);
|
||||
|
||||
const m4 = try Self.parse("text/html; boundary=----");
|
||||
try testing.expectEqualStrings("text", m4.mtype);
|
||||
try testing.expectEqualStrings("html", m4.msubtype);
|
||||
try testing.expectEqualStrings("boundary=----", m4.params);
|
||||
try testing.expectEqualStrings("----", m4.boundary.?);
|
||||
}
|
||||
|
||||
test "parse invalid" {
|
||||
for ([_][]const u8{
|
||||
"",
|
||||
"text",
|
||||
"text /html",
|
||||
"text/ html",
|
||||
"text / html",
|
||||
"text/html other",
|
||||
"text/html; x",
|
||||
"text/html; x=",
|
||||
"text/html; x= ",
|
||||
"text/html; = ",
|
||||
"text/html;=",
|
||||
"text/html; charset=\"\"",
|
||||
"text/html; charset=\"",
|
||||
"text/html; charset=\"\\",
|
||||
"text/html; charset=\"\\a\"", // invalid to escape non special characters
|
||||
};
|
||||
|
||||
for (invalids) |invalid| {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
|
||||
try testing.expectError(error.Invalid, Mime.parse(undefined, mutable_input));
|
||||
"te xt/html;",
|
||||
"te@xt/html;",
|
||||
"text/ht@ml;",
|
||||
"text/html;",
|
||||
"/text/html",
|
||||
"/html",
|
||||
}) |tc| {
|
||||
_ = Self.parse(tc) catch continue;
|
||||
try testing.expect(false);
|
||||
}
|
||||
}
|
||||
|
||||
test "Mime: parse common" {
|
||||
defer testing.reset();
|
||||
|
||||
try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml");
|
||||
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html");
|
||||
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain");
|
||||
|
||||
try expect(.{ .content_type = .{ .text_xml = {} } }, "text/xml;");
|
||||
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html;");
|
||||
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain;");
|
||||
|
||||
try expect(.{ .content_type = .{ .text_xml = {} } }, " \ttext/xml");
|
||||
try expect(.{ .content_type = .{ .text_html = {} } }, "text/html ");
|
||||
try expect(.{ .content_type = .{ .text_plain = {} } }, "text/plain \t\t");
|
||||
|
||||
try expect(.{ .content_type = .{ .text_xml = {} } }, "TEXT/xml");
|
||||
try expect(.{ .content_type = .{ .text_html = {} } }, "text/Html");
|
||||
try expect(.{ .content_type = .{ .text_plain = {} } }, "TEXT/PLAIN");
|
||||
|
||||
try expect(.{ .content_type = .{ .text_xml = {} } }, " TeXT/xml");
|
||||
try expect(.{ .content_type = .{ .text_html = {} } }, "teXt/HtML ;");
|
||||
try expect(.{ .content_type = .{ .text_plain = {} } }, "tExT/PlAiN;");
|
||||
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "text/javascript");
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "Application/JavaScript");
|
||||
try expect(.{ .content_type = .{ .text_javascript = {} } }, "application/x-javascript");
|
||||
|
||||
try expect(.{ .content_type = .{ .application_json = {} } }, "application/json");
|
||||
try expect(.{ .content_type = .{ .text_css = {} } }, "text/css");
|
||||
}
|
||||
|
||||
test "Mime: parse uncommon" {
|
||||
defer testing.reset();
|
||||
|
||||
const text_csv = Expectation{
|
||||
.content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } },
|
||||
};
|
||||
try expect(text_csv, "text/csv");
|
||||
try expect(text_csv, "text/csv;");
|
||||
try expect(text_csv, " text/csv\t ");
|
||||
try expect(text_csv, " text/csv\t ;");
|
||||
|
||||
try expect(
|
||||
.{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } },
|
||||
"Text/CSV",
|
||||
);
|
||||
}
|
||||
|
||||
test "Mime: parse charset" {
|
||||
defer testing.reset();
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "utf-8",
|
||||
.params = "charset=utf-8",
|
||||
}, "text/xml; charset=utf-8");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "utf-8",
|
||||
.params = "charset=\"utf-8\"",
|
||||
}, "text/xml;charset=\"utf-8\"");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "\\ \" ",
|
||||
.params = "charset=\"\\\\ \\\" \"",
|
||||
}, "text/xml;charset=\"\\\\ \\\" \" ");
|
||||
}
|
||||
|
||||
test "Mime: isHTML" {
|
||||
defer testing.reset();
|
||||
|
||||
const isHTML = struct {
|
||||
fn isHTML(expected: bool, input: []const u8) !void {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, input);
|
||||
var mime = try Mime.parse(testing.arena_allocator, mutable_input);
|
||||
try testing.expectEqual(expected, mime.isHTML());
|
||||
}
|
||||
}.isHTML;
|
||||
try isHTML(true, "text/html");
|
||||
try isHTML(true, "text/html;");
|
||||
try isHTML(true, "text/html; charset=utf-8");
|
||||
try isHTML(false, "text/htm"); // htm not html
|
||||
try isHTML(false, "text/plain");
|
||||
try isHTML(false, "over/9000");
|
||||
}
|
||||
|
||||
test "Mime: sniff" {
|
||||
try testing.expectEqual(null, Mime.sniff(""));
|
||||
try testing.expectEqual(null, Mime.sniff("<htm"));
|
||||
try testing.expectEqual(null, Mime.sniff("<html!"));
|
||||
try testing.expectEqual(null, Mime.sniff("<a_"));
|
||||
try testing.expectEqual(null, Mime.sniff("<!doctype html"));
|
||||
try testing.expectEqual(null, Mime.sniff("<!doctype html>"));
|
||||
try testing.expectEqual(null, Mime.sniff("\n <!doctype html>"));
|
||||
try testing.expectEqual(null, Mime.sniff("\n \t <font/>"));
|
||||
|
||||
const expectHTML = struct {
|
||||
fn expect(input: []const u8) !void {
|
||||
try testing.expectEqual(.text_html, std.meta.activeTag(Mime.sniff(input).?.content_type));
|
||||
}
|
||||
}.expect;
|
||||
|
||||
try expectHTML("<!doctype html ");
|
||||
try expectHTML("\n \t <!DOCTYPE HTML ");
|
||||
|
||||
try expectHTML("<html ");
|
||||
try expectHTML("\n \t <HtmL> even more stufff");
|
||||
|
||||
try expectHTML("<script>");
|
||||
try expectHTML("\n \t <SCRIpt >alert(document.cookies)</script>");
|
||||
|
||||
try expectHTML("<iframe>");
|
||||
try expectHTML(" \t <ifRAME >");
|
||||
|
||||
try expectHTML("<h1>");
|
||||
try expectHTML(" <H1>");
|
||||
|
||||
try expectHTML("<div>");
|
||||
try expectHTML("\n\r\r <DiV>");
|
||||
|
||||
try expectHTML("<font>");
|
||||
try expectHTML(" <fonT>");
|
||||
|
||||
try expectHTML("<table>");
|
||||
try expectHTML("\t\t<TAblE>");
|
||||
|
||||
try expectHTML("<a>");
|
||||
try expectHTML("\n\n<A>");
|
||||
|
||||
try expectHTML("<style>");
|
||||
try expectHTML(" \n\t <STyLE>");
|
||||
|
||||
try expectHTML("<title>");
|
||||
try expectHTML(" \n\t <TITLE>");
|
||||
|
||||
try expectHTML("<b>");
|
||||
try expectHTML(" \n\t <B>");
|
||||
|
||||
try expectHTML("<body>");
|
||||
try expectHTML(" \n\t <BODY>");
|
||||
|
||||
try expectHTML("<br>");
|
||||
try expectHTML(" \n\t <BR>");
|
||||
|
||||
try expectHTML("<p>");
|
||||
try expectHTML(" \n\t <P>");
|
||||
|
||||
try expectHTML("<!-->");
|
||||
try expectHTML(" \n\t <!-->");
|
||||
}
|
||||
|
||||
const Expectation = struct {
|
||||
content_type: Mime.ContentType,
|
||||
params: []const u8 = "",
|
||||
charset: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
fn expect(expected: Expectation, input: []const u8) !void {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, input);
|
||||
|
||||
const actual = try Mime.parse(testing.arena_allocator, mutable_input);
|
||||
try testing.expectEqual(
|
||||
std.meta.activeTag(expected.content_type),
|
||||
std.meta.activeTag(actual.content_type),
|
||||
);
|
||||
|
||||
switch (expected.content_type) {
|
||||
.other => |e| {
|
||||
const a = actual.content_type.other;
|
||||
try testing.expectEqual(e.type, a.type);
|
||||
try testing.expectEqual(e.sub_type, a.sub_type);
|
||||
},
|
||||
else => {}, // already asserted above
|
||||
}
|
||||
|
||||
try testing.expectEqual(expected.params, actual.params);
|
||||
|
||||
if (expected.charset) |ec| {
|
||||
try testing.expectEqual(ec, actual.charset.?);
|
||||
} else {
|
||||
try testing.expectEqual(null, actual.charset);
|
||||
}
|
||||
// Compare type and subtype.
|
||||
pub fn eql(self: Self, b: Self) bool {
|
||||
if (!std.mem.eql(u8, self.mtype, b.mtype)) return false;
|
||||
return std.mem.eql(u8, self.msubtype, b.msubtype);
|
||||
}
|
||||
|
||||
1124
src/browser/page.zig
1124
src/browser/page.zig
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
// fetch.js code comes from
|
||||
// https://github.com/JakeChampion/fetch/blob/main/fetch.js
|
||||
//
|
||||
// The original code source is available in MIT license.
|
||||
//
|
||||
// The script comes from the built version from npm.
|
||||
// You can get the package with the command:
|
||||
//
|
||||
// wget $(npm view whatwg-fetch dist.tarball)
|
||||
//
|
||||
// The source is the content of `package/dist/fetch.umd.js` file.
|
||||
pub const source = @embedFile("fetch.js");
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.fetch" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var ok = false;
|
||||
\\ const request = new Request("http://127.0.0.1:9582/loader");
|
||||
\\ fetch(request).then((response) => { ok = response.ok; });
|
||||
\\ false;
|
||||
,
|
||||
"false",
|
||||
},
|
||||
// all events have been resolved.
|
||||
.{ "ok", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ var ok2 = false;
|
||||
\\ const request2 = new Request("http://127.0.0.1:9582/loader");
|
||||
\\ (async function () { resp = await fetch(request2); ok2 = resp.ok; }());
|
||||
\\ false;
|
||||
,
|
||||
"false",
|
||||
},
|
||||
// all events have been resolved.
|
||||
.{ "ok2", "true" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
pub const Loader = struct {
|
||||
state: enum { empty, loading } = .empty,
|
||||
|
||||
done: struct {
|
||||
fetch: bool = false,
|
||||
webcomponents: bool = false,
|
||||
} = .{},
|
||||
|
||||
fn load(self: *Loader, comptime name: []const u8, source: []const u8, js_context: *Env.JsContext) void {
|
||||
var try_catch: Env.TryCatch = undefined;
|
||||
try_catch.init(js_context);
|
||||
defer try_catch.deinit();
|
||||
|
||||
self.state = .loading;
|
||||
defer self.state = .empty;
|
||||
|
||||
log.debug(.js, "polyfill load", .{ .name = name });
|
||||
_ = js_context.exec(source, name) catch |err| {
|
||||
log.fatal(.app, "polyfill error", .{
|
||||
.name = name,
|
||||
.err = try_catch.err(js_context.call_arena) catch @errorName(err) orelse @errorName(err),
|
||||
});
|
||||
};
|
||||
|
||||
@field(self.done, name) = true;
|
||||
}
|
||||
|
||||
// CompilationCallback implementation
|
||||
pub fn script(self: *Loader, src: []const u8, _: ?[]const u8, js_context: *Env.JsContext) void {
|
||||
if (!self.done.webcomponents and containsWebcomponents(src)) {
|
||||
const source = @import("webcomponents.zig").source;
|
||||
self.load("webcomponents", source, js_context);
|
||||
}
|
||||
}
|
||||
|
||||
// CompilationCallback implementation
|
||||
pub fn module(self: *Loader, src: []const u8, _: []const u8, js_context: *Env.JsContext) void {
|
||||
if (!self.done.webcomponents and containsWebcomponents(src)) {
|
||||
const source = @import("webcomponents.zig").source;
|
||||
self.load("webcomponents", source, js_context);
|
||||
}
|
||||
}
|
||||
|
||||
// GlobalMissingCallback implementation
|
||||
pub fn missing(self: *Loader, name: []const u8, js_context: *Env.JsContext) bool {
|
||||
// Avoid recursive calls during polyfill loading.
|
||||
if (self.state == .loading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self.done.fetch and isFetch(name)) {
|
||||
const source = @import("fetch.zig").source;
|
||||
self.load("fetch", source, js_context);
|
||||
|
||||
// We return false here: We want v8 to continue the calling chain
|
||||
// to finally find the polyfill we just inserted. If we want to
|
||||
// return false and stops the call chain, we have to use
|
||||
// `info.GetReturnValue.Set()` function, or `undefined` will be
|
||||
// returned immediately.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!self.done.webcomponents and isWebcomponents(name)) {
|
||||
const source = @import("webcomponents.zig").source;
|
||||
self.load("webcomponents", source, js_context);
|
||||
|
||||
// We return false here: We want v8 to continue the calling chain
|
||||
// to finally find the polyfill we just inserted. If we want to
|
||||
// return false and stops the call chain, we have to use
|
||||
// `info.GetReturnValue.Set()` function, or `undefined` will be
|
||||
// returned immediately.
|
||||
return false;
|
||||
}
|
||||
|
||||
if (comptime builtin.mode == .Debug) {
|
||||
log.debug(.unknown_prop, "unkown global property", .{
|
||||
.info = "but the property can exist in pure JS",
|
||||
.property = name,
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isFetch(name: []const u8) bool {
|
||||
if (std.mem.eql(u8, name, "fetch")) return true;
|
||||
if (std.mem.eql(u8, name, "Request")) return true;
|
||||
if (std.mem.eql(u8, name, "Response")) return true;
|
||||
if (std.mem.eql(u8, name, "Headers")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
fn isWebcomponents(name: []const u8) bool {
|
||||
if (std.mem.eql(u8, name, "customElements")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
fn containsWebcomponents(src: []const u8) bool {
|
||||
return std.mem.indexOf(u8, src, " extends ") != null;
|
||||
}
|
||||
};
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
@license @nocompile
|
||||
Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
|
||||
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
|
||||
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
|
||||
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
|
||||
Code distributed by Google as part of the polymer project is also
|
||||
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
|
||||
*/
|
||||
(function(){/*
|
||||
|
||||
Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
|
||||
This code may only be used under the BSD style license found at
|
||||
http://polymer.github.io/LICENSE.txt The complete set of authors may be found
|
||||
at http://polymer.github.io/AUTHORS.txt The complete set of contributors may
|
||||
be found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by
|
||||
Google as part of the polymer project is also subject to an additional IP
|
||||
rights grant found at http://polymer.github.io/PATENTS.txt
|
||||
*/
|
||||
'use strict';var n=window.Document.prototype.createElement,p=window.Document.prototype.createElementNS,aa=window.Document.prototype.importNode,ba=window.Document.prototype.prepend,ca=window.Document.prototype.append,da=window.DocumentFragment.prototype.prepend,ea=window.DocumentFragment.prototype.append,q=window.Node.prototype.cloneNode,r=window.Node.prototype.appendChild,t=window.Node.prototype.insertBefore,u=window.Node.prototype.removeChild,v=window.Node.prototype.replaceChild,w=Object.getOwnPropertyDescriptor(window.Node.prototype,
|
||||
"textContent"),y=window.Element.prototype.attachShadow,z=Object.getOwnPropertyDescriptor(window.Element.prototype,"innerHTML"),A=window.Element.prototype.getAttribute,B=window.Element.prototype.setAttribute,C=window.Element.prototype.removeAttribute,D=window.Element.prototype.toggleAttribute,E=window.Element.prototype.getAttributeNS,F=window.Element.prototype.setAttributeNS,G=window.Element.prototype.removeAttributeNS,H=window.Element.prototype.insertAdjacentElement,fa=window.Element.prototype.insertAdjacentHTML,
|
||||
ha=window.Element.prototype.prepend,ia=window.Element.prototype.append,ja=window.Element.prototype.before,ka=window.Element.prototype.after,la=window.Element.prototype.replaceWith,ma=window.Element.prototype.remove,na=window.HTMLElement,I=Object.getOwnPropertyDescriptor(window.HTMLElement.prototype,"innerHTML"),oa=window.HTMLElement.prototype.insertAdjacentElement,pa=window.HTMLElement.prototype.insertAdjacentHTML;var qa=new Set;"annotation-xml color-profile font-face font-face-src font-face-uri font-face-format font-face-name missing-glyph".split(" ").forEach(function(a){return qa.add(a)});function ra(a){var b=qa.has(a);a=/^[a-z][.0-9_a-z]*-[-.0-9_a-z]*$/.test(a);return!b&&a}var sa=document.contains?document.contains.bind(document):document.documentElement.contains.bind(document.documentElement);
|
||||
function J(a){var b=a.isConnected;if(void 0!==b)return b;if(sa(a))return!0;for(;a&&!(a.__CE_isImportDocument||a instanceof Document);)a=a.parentNode||(window.ShadowRoot&&a instanceof ShadowRoot?a.host:void 0);return!(!a||!(a.__CE_isImportDocument||a instanceof Document))}function K(a){var b=a.children;if(b)return Array.prototype.slice.call(b);b=[];for(a=a.firstChild;a;a=a.nextSibling)a.nodeType===Node.ELEMENT_NODE&&b.push(a);return b}
|
||||
function L(a,b){for(;b&&b!==a&&!b.nextSibling;)b=b.parentNode;return b&&b!==a?b.nextSibling:null}
|
||||
function M(a,b,d){for(var f=a;f;){if(f.nodeType===Node.ELEMENT_NODE){var c=f;b(c);var e=c.localName;if("link"===e&&"import"===c.getAttribute("rel")){f=c.import;void 0===d&&(d=new Set);if(f instanceof Node&&!d.has(f))for(d.add(f),f=f.firstChild;f;f=f.nextSibling)M(f,b,d);f=L(a,c);continue}else if("template"===e){f=L(a,c);continue}if(c=c.__CE_shadowRoot)for(c=c.firstChild;c;c=c.nextSibling)M(c,b,d)}f=f.firstChild?f.firstChild:L(a,f)}};function N(){var a=!(null===O||void 0===O||!O.noDocumentConstructionObserver),b=!(null===O||void 0===O||!O.shadyDomFastWalk);this.m=[];this.g=[];this.j=!1;this.shadyDomFastWalk=b;this.I=!a}function P(a,b,d,f){var c=window.ShadyDOM;if(a.shadyDomFastWalk&&c&&c.inUse){if(b.nodeType===Node.ELEMENT_NODE&&d(b),b.querySelectorAll)for(a=c.nativeMethods.querySelectorAll.call(b,"*"),b=0;b<a.length;b++)d(a[b])}else M(b,d,f)}function ta(a,b){a.j=!0;a.m.push(b)}function ua(a,b){a.j=!0;a.g.push(b)}
|
||||
function Q(a,b){a.j&&P(a,b,function(d){return R(a,d)})}function R(a,b){if(a.j&&!b.__CE_patched){b.__CE_patched=!0;for(var d=0;d<a.m.length;d++)a.m[d](b);for(d=0;d<a.g.length;d++)a.g[d](b)}}function S(a,b){var d=[];P(a,b,function(c){return d.push(c)});for(b=0;b<d.length;b++){var f=d[b];1===f.__CE_state?a.connectedCallback(f):T(a,f)}}function U(a,b){var d=[];P(a,b,function(c){return d.push(c)});for(b=0;b<d.length;b++){var f=d[b];1===f.__CE_state&&a.disconnectedCallback(f)}}
|
||||
function V(a,b,d){d=void 0===d?{}:d;var f=d.J,c=d.upgrade||function(g){return T(a,g)},e=[];P(a,b,function(g){a.j&&R(a,g);if("link"===g.localName&&"import"===g.getAttribute("rel")){var h=g.import;h instanceof Node&&(h.__CE_isImportDocument=!0,h.__CE_registry=document.__CE_registry);h&&"complete"===h.readyState?h.__CE_documentLoadHandled=!0:g.addEventListener("load",function(){var k=g.import;if(!k.__CE_documentLoadHandled){k.__CE_documentLoadHandled=!0;var l=new Set;f&&(f.forEach(function(m){return l.add(m)}),
|
||||
l.delete(k));V(a,k,{J:l,upgrade:c})}})}else e.push(g)},f);for(b=0;b<e.length;b++)c(e[b])}
|
||||
function T(a,b){try{var d=b.ownerDocument,f=d.__CE_registry;var c=f&&(d.defaultView||d.__CE_isImportDocument)?W(f,b.localName):void 0;if(c&&void 0===b.__CE_state){c.constructionStack.push(b);try{try{if(new c.constructorFunction!==b)throw Error("The custom element constructor did not produce the element being upgraded.");}finally{c.constructionStack.pop()}}catch(k){throw b.__CE_state=2,k;}b.__CE_state=1;b.__CE_definition=c;if(c.attributeChangedCallback&&b.hasAttributes()){var e=c.observedAttributes;
|
||||
for(c=0;c<e.length;c++){var g=e[c],h=b.getAttribute(g);null!==h&&a.attributeChangedCallback(b,g,null,h,null)}}J(b)&&a.connectedCallback(b)}}catch(k){X(k)}}N.prototype.connectedCallback=function(a){var b=a.__CE_definition;if(b.connectedCallback)try{b.connectedCallback.call(a)}catch(d){X(d)}};N.prototype.disconnectedCallback=function(a){var b=a.__CE_definition;if(b.disconnectedCallback)try{b.disconnectedCallback.call(a)}catch(d){X(d)}};
|
||||
N.prototype.attributeChangedCallback=function(a,b,d,f,c){var e=a.__CE_definition;if(e.attributeChangedCallback&&-1<e.observedAttributes.indexOf(b))try{e.attributeChangedCallback.call(a,b,d,f,c)}catch(g){X(g)}};
|
||||
function va(a,b,d,f){var c=b.__CE_registry;if(c&&(null===f||"http://www.w3.org/1999/xhtml"===f)&&(c=W(c,d)))try{var e=new c.constructorFunction;if(void 0===e.__CE_state||void 0===e.__CE_definition)throw Error("Failed to construct '"+d+"': The returned value was not constructed with the HTMLElement constructor.");if("http://www.w3.org/1999/xhtml"!==e.namespaceURI)throw Error("Failed to construct '"+d+"': The constructed element's namespace must be the HTML namespace.");if(e.hasAttributes())throw Error("Failed to construct '"+
|
||||
d+"': The constructed element must not have any attributes.");if(null!==e.firstChild)throw Error("Failed to construct '"+d+"': The constructed element must not have any children.");if(null!==e.parentNode)throw Error("Failed to construct '"+d+"': The constructed element must not have a parent node.");if(e.ownerDocument!==b)throw Error("Failed to construct '"+d+"': The constructed element's owner document is incorrect.");if(e.localName!==d)throw Error("Failed to construct '"+d+"': The constructed element's local name is incorrect.");
|
||||
return e}catch(g){return X(g),b=null===f?n.call(b,d):p.call(b,f,d),Object.setPrototypeOf(b,HTMLUnknownElement.prototype),b.__CE_state=2,b.__CE_definition=void 0,R(a,b),b}b=null===f?n.call(b,d):p.call(b,f,d);R(a,b);return b}
|
||||
function X(a){var b="",d="",f=0,c=0;a instanceof Error?(b=a.message,d=a.sourceURL||a.fileName||"",f=a.line||a.lineNumber||0,c=a.column||a.columnNumber||0):b="Uncaught "+String(a);var e=void 0;void 0===ErrorEvent.prototype.initErrorEvent?e=new ErrorEvent("error",{cancelable:!0,message:b,filename:d,lineno:f,colno:c,error:a}):(e=document.createEvent("ErrorEvent"),e.initErrorEvent("error",!1,!0,b,d,f),e.preventDefault=function(){Object.defineProperty(this,"defaultPrevented",{configurable:!0,get:function(){return!0}})});
|
||||
void 0===e.error&&Object.defineProperty(e,"error",{configurable:!0,enumerable:!0,get:function(){return a}});window.dispatchEvent(e);e.defaultPrevented||console.error(a)};function wa(){var a=this;this.g=void 0;this.F=new Promise(function(b){a.l=b})}wa.prototype.resolve=function(a){if(this.g)throw Error("Already resolved.");this.g=a;this.l(a)};function xa(a){var b=document;this.l=void 0;this.h=a;this.g=b;V(this.h,this.g);"loading"===this.g.readyState&&(this.l=new MutationObserver(this.G.bind(this)),this.l.observe(this.g,{childList:!0,subtree:!0}))}function ya(a){a.l&&a.l.disconnect()}xa.prototype.G=function(a){var b=this.g.readyState;"interactive"!==b&&"complete"!==b||ya(this);for(b=0;b<a.length;b++)for(var d=a[b].addedNodes,f=0;f<d.length;f++)V(this.h,d[f])};function Y(a){this.s=new Map;this.u=new Map;this.C=new Map;this.A=!1;this.B=new Map;this.o=function(b){return b()};this.i=!1;this.v=[];this.h=a;this.D=a.I?new xa(a):void 0}Y.prototype.H=function(a,b){var d=this;if(!(b instanceof Function))throw new TypeError("Custom element constructor getters must be functions.");za(this,a);this.s.set(a,b);this.v.push(a);this.i||(this.i=!0,this.o(function(){return Aa(d)}))};
|
||||
Y.prototype.define=function(a,b){var d=this;if(!(b instanceof Function))throw new TypeError("Custom element constructors must be functions.");za(this,a);Ba(this,a,b);this.v.push(a);this.i||(this.i=!0,this.o(function(){return Aa(d)}))};function za(a,b){if(!ra(b))throw new SyntaxError("The element name '"+b+"' is not valid.");if(W(a,b))throw Error("A custom element with name '"+(b+"' has already been defined."));if(a.A)throw Error("A custom element is already being defined.");}
|
||||
function Ba(a,b,d){a.A=!0;var f;try{var c=d.prototype;if(!(c instanceof Object))throw new TypeError("The custom element constructor's prototype is not an object.");var e=function(m){var x=c[m];if(void 0!==x&&!(x instanceof Function))throw Error("The '"+m+"' callback must be a function.");return x};var g=e("connectedCallback");var h=e("disconnectedCallback");var k=e("adoptedCallback");var l=(f=e("attributeChangedCallback"))&&d.observedAttributes||[]}catch(m){throw m;}finally{a.A=!1}d={localName:b,
|
||||
constructorFunction:d,connectedCallback:g,disconnectedCallback:h,adoptedCallback:k,attributeChangedCallback:f,observedAttributes:l,constructionStack:[]};a.u.set(b,d);a.C.set(d.constructorFunction,d);return d}Y.prototype.upgrade=function(a){V(this.h,a)};
|
||||
function Aa(a){if(!1!==a.i){a.i=!1;for(var b=[],d=a.v,f=new Map,c=0;c<d.length;c++)f.set(d[c],[]);V(a.h,document,{upgrade:function(k){if(void 0===k.__CE_state){var l=k.localName,m=f.get(l);m?m.push(k):a.u.has(l)&&b.push(k)}}});for(c=0;c<b.length;c++)T(a.h,b[c]);for(c=0;c<d.length;c++){for(var e=d[c],g=f.get(e),h=0;h<g.length;h++)T(a.h,g[h]);(e=a.B.get(e))&&e.resolve(void 0)}d.length=0}}Y.prototype.get=function(a){if(a=W(this,a))return a.constructorFunction};
|
||||
Y.prototype.whenDefined=function(a){if(!ra(a))return Promise.reject(new SyntaxError("'"+a+"' is not a valid custom element name."));var b=this.B.get(a);if(b)return b.F;b=new wa;this.B.set(a,b);var d=this.u.has(a)||this.s.has(a);a=-1===this.v.indexOf(a);d&&a&&b.resolve(void 0);return b.F};Y.prototype.polyfillWrapFlushCallback=function(a){this.D&&ya(this.D);var b=this.o;this.o=function(d){return a(function(){return b(d)})}};
|
||||
function W(a,b){var d=a.u.get(b);if(d)return d;if(d=a.s.get(b)){a.s.delete(b);try{return Ba(a,b,d())}catch(f){X(f)}}}Y.prototype.define=Y.prototype.define;Y.prototype.upgrade=Y.prototype.upgrade;Y.prototype.get=Y.prototype.get;Y.prototype.whenDefined=Y.prototype.whenDefined;Y.prototype.polyfillDefineLazy=Y.prototype.H;Y.prototype.polyfillWrapFlushCallback=Y.prototype.polyfillWrapFlushCallback;function Z(a,b,d){function f(c){return function(e){for(var g=[],h=0;h<arguments.length;++h)g[h]=arguments[h];h=[];for(var k=[],l=0;l<g.length;l++){var m=g[l];m instanceof Element&&J(m)&&k.push(m);if(m instanceof DocumentFragment)for(m=m.firstChild;m;m=m.nextSibling)h.push(m);else h.push(m)}c.apply(this,g);for(g=0;g<k.length;g++)U(a,k[g]);if(J(this))for(g=0;g<h.length;g++)k=h[g],k instanceof Element&&S(a,k)}}void 0!==d.prepend&&(b.prepend=f(d.prepend));void 0!==d.append&&(b.append=f(d.append))};function Ca(a){Document.prototype.createElement=function(b){return va(a,this,b,null)};Document.prototype.importNode=function(b,d){b=aa.call(this,b,!!d);this.__CE_registry?V(a,b):Q(a,b);return b};Document.prototype.createElementNS=function(b,d){return va(a,this,d,b)};Z(a,Document.prototype,{prepend:ba,append:ca})};function Da(a){function b(f){return function(c){for(var e=[],g=0;g<arguments.length;++g)e[g]=arguments[g];g=[];for(var h=[],k=0;k<e.length;k++){var l=e[k];l instanceof Element&&J(l)&&h.push(l);if(l instanceof DocumentFragment)for(l=l.firstChild;l;l=l.nextSibling)g.push(l);else g.push(l)}f.apply(this,e);for(e=0;e<h.length;e++)U(a,h[e]);if(J(this))for(e=0;e<g.length;e++)h=g[e],h instanceof Element&&S(a,h)}}var d=Element.prototype;void 0!==ja&&(d.before=b(ja));void 0!==ka&&(d.after=b(ka));void 0!==la&&
|
||||
(d.replaceWith=function(f){for(var c=[],e=0;e<arguments.length;++e)c[e]=arguments[e];e=[];for(var g=[],h=0;h<c.length;h++){var k=c[h];k instanceof Element&&J(k)&&g.push(k);if(k instanceof DocumentFragment)for(k=k.firstChild;k;k=k.nextSibling)e.push(k);else e.push(k)}h=J(this);la.apply(this,c);for(c=0;c<g.length;c++)U(a,g[c]);if(h)for(U(a,this),c=0;c<e.length;c++)g=e[c],g instanceof Element&&S(a,g)});void 0!==ma&&(d.remove=function(){var f=J(this);ma.call(this);f&&U(a,this)})};function Ea(a){function b(c,e){Object.defineProperty(c,"innerHTML",{enumerable:e.enumerable,configurable:!0,get:e.get,set:function(g){var h=this,k=void 0;J(this)&&(k=[],P(a,this,function(x){x!==h&&k.push(x)}));e.set.call(this,g);if(k)for(var l=0;l<k.length;l++){var m=k[l];1===m.__CE_state&&a.disconnectedCallback(m)}this.ownerDocument.__CE_registry?V(a,this):Q(a,this);return g}})}function d(c,e){c.insertAdjacentElement=function(g,h){var k=J(h);g=e.call(this,g,h);k&&U(a,h);J(g)&&S(a,h);return g}}function f(c,
|
||||
e){function g(h,k){for(var l=[];h!==k;h=h.nextSibling)l.push(h);for(k=0;k<l.length;k++)V(a,l[k])}c.insertAdjacentHTML=function(h,k){h=h.toLowerCase();if("beforebegin"===h){var l=this.previousSibling;e.call(this,h,k);g(l||this.parentNode.firstChild,this)}else if("afterbegin"===h)l=this.firstChild,e.call(this,h,k),g(this.firstChild,l);else if("beforeend"===h)l=this.lastChild,e.call(this,h,k),g(l||this.firstChild,null);else if("afterend"===h)l=this.nextSibling,e.call(this,h,k),g(this.nextSibling,l);
|
||||
else throw new SyntaxError("The value provided ("+String(h)+") is not one of 'beforebegin', 'afterbegin', 'beforeend', or 'afterend'.");}}y&&(Element.prototype.attachShadow=function(c){c=y.call(this,c);if(a.j&&!c.__CE_patched){c.__CE_patched=!0;for(var e=0;e<a.m.length;e++)a.m[e](c)}return this.__CE_shadowRoot=c});z&&z.get?b(Element.prototype,z):I&&I.get?b(HTMLElement.prototype,I):ua(a,function(c){b(c,{enumerable:!0,configurable:!0,get:function(){return q.call(this,!0).innerHTML},set:function(e){var g=
|
||||
"template"===this.localName,h=g?this.content:this,k=p.call(document,this.namespaceURI,this.localName);for(k.innerHTML=e;0<h.childNodes.length;)u.call(h,h.childNodes[0]);for(e=g?k.content:k;0<e.childNodes.length;)r.call(h,e.childNodes[0])}})});Element.prototype.setAttribute=function(c,e){if(1!==this.__CE_state)return B.call(this,c,e);var g=A.call(this,c);B.call(this,c,e);e=A.call(this,c);a.attributeChangedCallback(this,c,g,e,null)};Element.prototype.setAttributeNS=function(c,e,g){if(1!==this.__CE_state)return F.call(this,
|
||||
c,e,g);var h=E.call(this,c,e);F.call(this,c,e,g);g=E.call(this,c,e);a.attributeChangedCallback(this,e,h,g,c)};Element.prototype.removeAttribute=function(c){if(1!==this.__CE_state)return C.call(this,c);var e=A.call(this,c);C.call(this,c);null!==e&&a.attributeChangedCallback(this,c,e,null,null)};D&&(Element.prototype.toggleAttribute=function(c,e){if(1!==this.__CE_state)return D.call(this,c,e);var g=A.call(this,c),h=null!==g;e=D.call(this,c,e);h!==e&&a.attributeChangedCallback(this,c,g,e?"":null,null);
|
||||
return e});Element.prototype.removeAttributeNS=function(c,e){if(1!==this.__CE_state)return G.call(this,c,e);var g=E.call(this,c,e);G.call(this,c,e);var h=E.call(this,c,e);g!==h&&a.attributeChangedCallback(this,e,g,h,c)};oa?d(HTMLElement.prototype,oa):H&&d(Element.prototype,H);pa?f(HTMLElement.prototype,pa):fa&&f(Element.prototype,fa);Z(a,Element.prototype,{prepend:ha,append:ia});Da(a)};var Fa={};function Ga(a){function b(){var d=this.constructor;var f=document.__CE_registry.C.get(d);if(!f)throw Error("Failed to construct a custom element: The constructor was not registered with `customElements`.");var c=f.constructionStack;if(0===c.length)return c=n.call(document,f.localName),Object.setPrototypeOf(c,d.prototype),c.__CE_state=1,c.__CE_definition=f,R(a,c),c;var e=c.length-1,g=c[e];if(g===Fa)throw Error("Failed to construct '"+f.localName+"': This element was already constructed.");c[e]=Fa;
|
||||
Object.setPrototypeOf(g,d.prototype);R(a,g);return g}b.prototype=na.prototype;Object.defineProperty(HTMLElement.prototype,"constructor",{writable:!0,configurable:!0,enumerable:!1,value:b});window.HTMLElement=b};function Ha(a){function b(d,f){Object.defineProperty(d,"textContent",{enumerable:f.enumerable,configurable:!0,get:f.get,set:function(c){if(this.nodeType===Node.TEXT_NODE)f.set.call(this,c);else{var e=void 0;if(this.firstChild){var g=this.childNodes,h=g.length;if(0<h&&J(this)){e=Array(h);for(var k=0;k<h;k++)e[k]=g[k]}}f.set.call(this,c);if(e)for(c=0;c<e.length;c++)U(a,e[c])}}})}Node.prototype.insertBefore=function(d,f){if(d instanceof DocumentFragment){var c=K(d);d=t.call(this,d,f);if(J(this))for(f=
|
||||
0;f<c.length;f++)S(a,c[f]);return d}c=d instanceof Element&&J(d);f=t.call(this,d,f);c&&U(a,d);J(this)&&S(a,d);return f};Node.prototype.appendChild=function(d){if(d instanceof DocumentFragment){var f=K(d);d=r.call(this,d);if(J(this))for(var c=0;c<f.length;c++)S(a,f[c]);return d}f=d instanceof Element&&J(d);c=r.call(this,d);f&&U(a,d);J(this)&&S(a,d);return c};Node.prototype.cloneNode=function(d){d=q.call(this,!!d);this.ownerDocument.__CE_registry?V(a,d):Q(a,d);return d};Node.prototype.removeChild=function(d){var f=
|
||||
d instanceof Element&&J(d),c=u.call(this,d);f&&U(a,d);return c};Node.prototype.replaceChild=function(d,f){if(d instanceof DocumentFragment){var c=K(d);d=v.call(this,d,f);if(J(this))for(U(a,f),f=0;f<c.length;f++)S(a,c[f]);return d}c=d instanceof Element&&J(d);var e=v.call(this,d,f),g=J(this);g&&U(a,f);c&&U(a,d);g&&S(a,d);return e};w&&w.get?b(Node.prototype,w):ta(a,function(d){b(d,{enumerable:!0,configurable:!0,get:function(){for(var f=[],c=this.firstChild;c;c=c.nextSibling)c.nodeType!==Node.COMMENT_NODE&&
|
||||
f.push(c.textContent);return f.join("")},set:function(f){for(;this.firstChild;)u.call(this,this.firstChild);null!=f&&""!==f&&r.call(this,document.createTextNode(f))}})})};var O=window.customElements;function Ia(){var a=new N;Ga(a);Ca(a);Z(a,DocumentFragment.prototype,{prepend:da,append:ea});Ha(a);Ea(a);window.CustomElementRegistry=Y;a=new Y(a);document.__CE_registry=a;Object.defineProperty(window,"customElements",{configurable:!0,enumerable:!0,value:a})}O&&!O.forcePolyfill&&"function"==typeof O.define&&"function"==typeof O.get||Ia();window.__CE_installPolyfill=Ia;/*
|
||||
|
||||
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
|
||||
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
|
||||
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
|
||||
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
|
||||
Code distributed by Google as part of the polymer project is also
|
||||
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
|
||||
*/
|
||||
}).call(this);
|
||||
@@ -1,33 +0,0 @@
|
||||
// webcomponents.js code comes from
|
||||
// https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs
|
||||
//
|
||||
// The original code source is available in a "BSD style license".
|
||||
//
|
||||
// This is the `webcomponents-ce.js` bundle
|
||||
pub const source = @embedFile("webcomponents.js");
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.webcomponents" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<div id=main></div>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{
|
||||
\\ class LightPanda extends HTMLElement {
|
||||
\\ constructor() {
|
||||
\\ super();
|
||||
\\ }
|
||||
\\ connectedCallback() {
|
||||
\\ this.append('connected')
|
||||
\\ }
|
||||
\\ }
|
||||
\\ window.customElements.define("lightpanda-test", LightPanda);
|
||||
\\ const main = document.getElementById('main');
|
||||
\\ main.appendChild(document.createElement('lightpanda-test'));
|
||||
,
|
||||
null,
|
||||
},
|
||||
|
||||
.{ "main.innerHTML", "<lightpanda-test>connected</lightpanda-test>" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
const _x: f64 = @floatFromInt(x);
|
||||
const y: f64 = 0.0;
|
||||
const w: f64 = 1.0;
|
||||
const h: f64 = 1.0;
|
||||
|
||||
return .{
|
||||
.x = _x,
|
||||
.y = y,
|
||||
.width = w,
|
||||
.height = h,
|
||||
.left = _x,
|
||||
.top = y,
|
||||
.right = _x + w,
|
||||
.bottom = y + h,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn boundingRect(self: *const FlatRenderer) Element.DOMRect {
|
||||
const x: f64 = 0.0;
|
||||
const y: f64 = 0.0;
|
||||
const w: f64 = @floatFromInt(self.width());
|
||||
const h: f64 = @floatFromInt(self.width());
|
||||
|
||||
return .{
|
||||
.x = x,
|
||||
.y = y,
|
||||
.width = w,
|
||||
.height = h,
|
||||
.left = x,
|
||||
.top = y,
|
||||
.right = x + w,
|
||||
.bottom = y + h,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
// 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 URL = @import("../url.zig").URL;
|
||||
const Browser = @import("browser.zig").Browser;
|
||||
const NavigateOpts = @import("page.zig").NavigateOpts;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const parser = @import("netsurf.zig");
|
||||
const storage = @import("storage/storage.zig");
|
||||
|
||||
// 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.browser.state_pool.reset(.{ .retain_with_limit = 4 * 1024 });
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, page_arena.allocator(), self);
|
||||
|
||||
log.debug(.browser, "create page", .{});
|
||||
// start JS env
|
||||
// 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);
|
||||
|
||||
// Cleanup is a bit sensitive. We could still have inflight I/O. For
|
||||
// example, we could have an XHR request which is still in the connect
|
||||
// phase. It's important that we clean these up, as they're holding onto
|
||||
// limited resources (like our fixed-sized http state pool).
|
||||
//
|
||||
// First thing we do, is removeJsContext() which will execute the destructor
|
||||
// of any type that registered a destructor (e.g. XMLHttpRequest).
|
||||
// This will shutdown any pending sockets, which begins our cleaning
|
||||
// processed
|
||||
self.executor.removeJsContext();
|
||||
|
||||
// Second thing we do is reset the loop. This increments the loop ctx_id
|
||||
// so that any "stale" timeouts we process will get ignored. We need to
|
||||
// do this BEFORE running the loop because, at this point, things like
|
||||
// window.setTimeout and running microtasks should be ignored
|
||||
self.browser.app.loop.reset();
|
||||
|
||||
self.page = null;
|
||||
|
||||
// clear netsurf memory arena.
|
||||
parser.deinit();
|
||||
|
||||
log.debug(.browser, "remove page", .{});
|
||||
}
|
||||
|
||||
pub fn currentPage(self: *Session) ?*Page {
|
||||
return &(self.page orelse return null);
|
||||
}
|
||||
};
|
||||
@@ -1,961 +0,0 @@
|
||||
const std = @import("std");
|
||||
const Uri = std.Uri;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const log = @import("../../log.zig");
|
||||
const http = @import("../../http/client.zig");
|
||||
const DateTime = @import("../../datetime.zig").DateTime;
|
||||
const public_suffix_list = @import("../../data/public_suffix_list.zig").lookup;
|
||||
|
||||
pub const LookupOpts = struct {
|
||||
request_time: ?i64 = null,
|
||||
origin_uri: ?*const Uri = null,
|
||||
navigation: bool = true,
|
||||
is_http: bool,
|
||||
};
|
||||
|
||||
pub const Jar = struct {
|
||||
allocator: Allocator,
|
||||
cookies: std.ArrayListUnmanaged(Cookie),
|
||||
|
||||
pub fn init(allocator: Allocator) Jar {
|
||||
return .{
|
||||
.cookies = .{},
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Jar) void {
|
||||
for (self.cookies.items) |c| {
|
||||
c.deinit();
|
||||
}
|
||||
self.cookies.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn clearRetainingCapacity(self: *Jar) void {
|
||||
for (self.cookies.items) |c| {
|
||||
c.deinit();
|
||||
}
|
||||
self.cookies.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
pub fn add(
|
||||
self: *Jar,
|
||||
cookie: Cookie,
|
||||
request_time: i64,
|
||||
) !void {
|
||||
const is_expired = isCookieExpired(&cookie, request_time);
|
||||
defer if (is_expired) {
|
||||
cookie.deinit();
|
||||
};
|
||||
|
||||
for (self.cookies.items, 0..) |*c, i| {
|
||||
if (areCookiesEqual(&cookie, c)) {
|
||||
c.deinit();
|
||||
if (is_expired) {
|
||||
_ = self.cookies.swapRemove(i);
|
||||
} else {
|
||||
self.cookies.items[i] = cookie;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_expired) {
|
||||
try self.cookies.append(self.allocator, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn removeExpired(self: *Jar, request_time: ?i64) void {
|
||||
if (self.cookies.items.len == 0) return;
|
||||
const time = request_time orelse std.time.timestamp();
|
||||
var i: usize = self.cookies.items.len - 1;
|
||||
while (i > 0) {
|
||||
defer i -= 1;
|
||||
const cookie = &self.cookies.items[i];
|
||||
if (isCookieExpired(cookie, time)) {
|
||||
self.cookies.swapRemove(i).deinit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forRequest(self: *Jar, target_uri: *const Uri, writer: anytype, opts: LookupOpts) !void {
|
||||
const target = PreparedUri{
|
||||
.host = (target_uri.host orelse return error.InvalidURI).percent_encoded,
|
||||
.path = target_uri.path.percent_encoded,
|
||||
.secure = std.mem.eql(u8, target_uri.scheme, "https"),
|
||||
};
|
||||
const same_site = try areSameSite(opts.origin_uri, target.host);
|
||||
|
||||
removeExpired(self, opts.request_time);
|
||||
|
||||
var first = true;
|
||||
for (self.cookies.items) |*cookie| {
|
||||
if (!cookie.appliesTo(&target, same_site, opts.navigation, opts.is_http)) continue;
|
||||
|
||||
// we have a match!
|
||||
if (first) {
|
||||
first = false;
|
||||
} else {
|
||||
try writer.writeAll("; ");
|
||||
}
|
||||
try writeCookie(cookie, writer);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn populateFromResponse(self: *Jar, uri: *const Uri, header: *const http.ResponseHeader) !void {
|
||||
const now = std.time.timestamp();
|
||||
var it = header.iterate("set-cookie");
|
||||
while (it.next()) |set_cookie| {
|
||||
const c = Cookie.parse(self.allocator, uri, set_cookie) catch |err| {
|
||||
log.warn(.web_api, "cookie parse failed", .{ .raw = set_cookie, .err = err });
|
||||
continue;
|
||||
};
|
||||
try self.add(c, now);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeCookie(cookie: *const Cookie, writer: anytype) !void {
|
||||
if (cookie.name.len > 0) {
|
||||
try writer.writeAll(cookie.name);
|
||||
try writer.writeByte('=');
|
||||
}
|
||||
if (cookie.value.len > 0) {
|
||||
try writer.writeAll(cookie.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn isCookieExpired(cookie: *const Cookie, now: i64) bool {
|
||||
const ce = cookie.expires orelse return false;
|
||||
return ce <= @as(f64, @floatFromInt(now));
|
||||
}
|
||||
|
||||
fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool {
|
||||
if (std.mem.eql(u8, a.name, b.name) == false) {
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, a.domain, b.domain) == false) {
|
||||
return false;
|
||||
}
|
||||
if (std.mem.eql(u8, a.path, b.path) == false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn areSameSite(origin_uri_: ?*const std.Uri, target_host: []const u8) !bool {
|
||||
const origin_uri = origin_uri_ orelse return true;
|
||||
const origin_host = (origin_uri.host orelse return error.InvalidURI).percent_encoded;
|
||||
|
||||
// common case
|
||||
if (std.mem.eql(u8, target_host, origin_host)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return std.mem.eql(u8, findSecondLevelDomain(target_host), findSecondLevelDomain(origin_host));
|
||||
}
|
||||
|
||||
fn findSecondLevelDomain(host: []const u8) []const u8 {
|
||||
var i = std.mem.lastIndexOfScalar(u8, host, '.') orelse return host;
|
||||
while (true) {
|
||||
i = std.mem.lastIndexOfScalar(u8, host[0..i], '.') orelse return host;
|
||||
const strip = i + 1;
|
||||
if (public_suffix_list(host[strip..]) == false) {
|
||||
return host[strip..];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const Cookie = struct {
|
||||
arena: ArenaAllocator,
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
domain: []const u8,
|
||||
path: []const u8,
|
||||
expires: ?f64,
|
||||
secure: bool = false,
|
||||
http_only: bool = false,
|
||||
same_site: SameSite = .none,
|
||||
|
||||
const SameSite = enum {
|
||||
strict,
|
||||
lax,
|
||||
none,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *const Cookie) void {
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
// There's https://datatracker.ietf.org/doc/html/rfc6265 but browsers are
|
||||
// far less strict. I only found 2 cases where browsers will reject a cookie:
|
||||
// - a byte 0...32 and 127..255 anywhere in the cookie (the HTTP header
|
||||
// parser might take care of this already)
|
||||
// - any shenanigans with the domain attribute - it has to be the current
|
||||
// domain or one of higher order, exluding TLD.
|
||||
// Anything else, will turn into a cookie.
|
||||
// Single value? That's a cookie with an emtpy name and a value
|
||||
// Key or Values with characters the RFC says aren't allowed? Allowed! (
|
||||
// (as long as the characters are 32...126)
|
||||
// Invalid attributes? Ignored.
|
||||
// Invalid attribute values? Ignore.
|
||||
// Duplicate attributes - use the last valid
|
||||
// Value-less attributes with a value? Ignore the value
|
||||
pub fn parse(allocator: Allocator, uri: *const std.Uri, str: []const u8) !Cookie {
|
||||
if (str.len == 0) {
|
||||
// this check is necessary, `std.mem.minMax` asserts len > 0
|
||||
return error.Empty;
|
||||
}
|
||||
{
|
||||
const min, const max = std.mem.minMax(u8, str);
|
||||
if (min < 32 or max > 126) {
|
||||
return error.InvalidByteSequence;
|
||||
}
|
||||
}
|
||||
|
||||
const cookie_name, const cookie_value, const rest = parseNameValue(str) catch {
|
||||
return error.InvalidNameValue;
|
||||
};
|
||||
|
||||
var scrap: [8]u8 = undefined;
|
||||
|
||||
var path: ?[]const u8 = null;
|
||||
var domain: ?[]const u8 = null;
|
||||
var secure: ?bool = null;
|
||||
var max_age: ?i64 = null;
|
||||
var http_only: ?bool = null;
|
||||
var expires: ?[]const u8 = null;
|
||||
var same_site: ?Cookie.SameSite = null;
|
||||
|
||||
var it = std.mem.splitScalar(u8, rest, ';');
|
||||
while (it.next()) |attribute| {
|
||||
const sep = std.mem.indexOfScalarPos(u8, attribute, 0, '=') orelse attribute.len;
|
||||
const key_string = trim(attribute[0..sep]);
|
||||
|
||||
if (key_string.len > 8) {
|
||||
// not valid, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
// Make sure no one changes our max length without also expanding the size of scrap
|
||||
std.debug.assert(key_string.len <= 8);
|
||||
|
||||
const key = std.meta.stringToEnum(enum {
|
||||
path,
|
||||
domain,
|
||||
secure,
|
||||
@"max-age",
|
||||
expires,
|
||||
httponly,
|
||||
samesite,
|
||||
}, std.ascii.lowerString(&scrap, key_string)) orelse continue;
|
||||
|
||||
const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]);
|
||||
switch (key) {
|
||||
.path => path = value,
|
||||
.domain => domain = value,
|
||||
.secure => secure = true,
|
||||
.@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue,
|
||||
.expires => expires = value,
|
||||
.httponly => http_only = true,
|
||||
.samesite => {
|
||||
same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (same_site == .none and secure == null) {
|
||||
return error.InsecureSameSite;
|
||||
}
|
||||
|
||||
var arena = ArenaAllocator.init(allocator);
|
||||
errdefer arena.deinit();
|
||||
const aa = arena.allocator();
|
||||
const owned_name = try aa.dupe(u8, cookie_name);
|
||||
const owned_value = try aa.dupe(u8, cookie_value);
|
||||
const owned_path = try parsePath(aa, uri, path);
|
||||
const owned_domain = try parseDomain(aa, uri, domain);
|
||||
|
||||
var normalized_expires: ?f64 = null;
|
||||
if (max_age) |ma| {
|
||||
normalized_expires = @floatFromInt(std.time.timestamp() + ma);
|
||||
} else {
|
||||
// max age takes priority over expires
|
||||
if (expires) |expires_| {
|
||||
var exp_dt = DateTime.parse(expires_, .rfc822) catch null;
|
||||
if (exp_dt == null) {
|
||||
if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) {
|
||||
// Replace dashes and try again
|
||||
const output = try aa.dupe(u8, expires_);
|
||||
output[7] = ' ';
|
||||
output[11] = ' ';
|
||||
exp_dt = DateTime.parse(output, .rfc822) catch null;
|
||||
}
|
||||
}
|
||||
if (exp_dt) |dt| {
|
||||
normalized_expires = @floatFromInt(dt.unix(.seconds));
|
||||
} else {
|
||||
// Algolia, for example, will call document.setCookie with
|
||||
// an expired value which is literally 'Invalid Date'
|
||||
// (it's trying to do something like: `new Date() + undefined`).
|
||||
log.debug(.web_api, "cookie expires date", .{ .date = expires_ });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.arena = arena,
|
||||
.name = owned_name,
|
||||
.value = owned_value,
|
||||
.path = owned_path,
|
||||
.same_site = same_site orelse .lax,
|
||||
.secure = secure orelse false,
|
||||
.http_only = http_only orelse false,
|
||||
.domain = owned_domain,
|
||||
.expires = normalized_expires,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parsePath(arena: Allocator, uri: ?*const std.Uri, explicit_path: ?[]const u8) ![]const u8 {
|
||||
// path attribute value either begins with a '/' or we
|
||||
// ignore it and use the "default-path" algorithm
|
||||
if (explicit_path) |path| {
|
||||
if (path.len > 0 and path[0] == '/') {
|
||||
return try arena.dupe(u8, path);
|
||||
}
|
||||
}
|
||||
|
||||
// default-path
|
||||
const url_path = (uri orelse return "/").path;
|
||||
|
||||
const either = url_path.percent_encoded;
|
||||
if (either.len == 0 or (either.len == 1 and either[0] == '/')) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar);
|
||||
const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse {
|
||||
return "/";
|
||||
};
|
||||
return try arena.dupe(u8, owned_path[0 .. last + 1]);
|
||||
}
|
||||
|
||||
pub fn parseDomain(arena: Allocator, uri: ?*const std.Uri, explicit_domain: ?[]const u8) ![]const u8 {
|
||||
var encoded_host: ?[]const u8 = null;
|
||||
if (uri) |uri_| {
|
||||
const uri_host = uri_.host orelse return error.InvalidURI;
|
||||
const host = try percentEncode(arena, uri_host, isHostChar);
|
||||
_ = toLower(host);
|
||||
encoded_host = host;
|
||||
}
|
||||
|
||||
if (explicit_domain) |domain| {
|
||||
if (domain.len > 0) {
|
||||
const no_leading_dot = if (domain[0] == '.') domain[1..] else domain;
|
||||
|
||||
var list = std.ArrayList(u8).init(arena);
|
||||
try list.ensureTotalCapacity(no_leading_dot.len + 1); // Expect no precents needed
|
||||
list.appendAssumeCapacity('.');
|
||||
try std.Uri.Component.percentEncode(list.writer(), no_leading_dot, isHostChar);
|
||||
var owned_domain: []u8 = list.items; // @memory retains memory used before growing
|
||||
_ = toLower(owned_domain);
|
||||
|
||||
if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) {
|
||||
// can't set a cookie for a TLD
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
if (encoded_host) |host| {
|
||||
if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) {
|
||||
return error.InvalidDomain;
|
||||
}
|
||||
}
|
||||
|
||||
return owned_domain;
|
||||
}
|
||||
}
|
||||
|
||||
return encoded_host orelse return error.InvalidDomain; // default-domain
|
||||
}
|
||||
|
||||
pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 {
|
||||
switch (component) {
|
||||
.raw => |str| {
|
||||
var list = std.ArrayList(u8).init(arena);
|
||||
try list.ensureTotalCapacity(str.len); // Expect no precents needed
|
||||
try std.Uri.Component.percentEncode(list.writer(), str, isValidChar);
|
||||
return list.items; // @memory retains memory used before growing
|
||||
},
|
||||
.percent_encoded => |str| {
|
||||
return try arena.dupe(u8, str);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn isHostChar(c: u8) bool {
|
||||
return switch (c) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
|
||||
':' => true,
|
||||
'[', ']' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn isPathChar(c: u8) bool {
|
||||
return switch (c) {
|
||||
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
|
||||
'!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true,
|
||||
'/', ':', '@' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } {
|
||||
const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len;
|
||||
const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..];
|
||||
|
||||
const sep = std.mem.indexOfScalarPos(u8, str[0..key_value_end], 0, '=') orelse {
|
||||
const value = trim(str[0..key_value_end]);
|
||||
if (value.len == 0) {
|
||||
return error.Empty;
|
||||
}
|
||||
return .{ "", value, rest };
|
||||
};
|
||||
|
||||
const name = trim(str[0..sep]);
|
||||
const value = trim(str[sep + 1 .. key_value_end]);
|
||||
return .{ name, value, rest };
|
||||
}
|
||||
|
||||
pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool, is_http: bool) bool {
|
||||
if (self.http_only and is_http == false) {
|
||||
// http only cookies can be accessed from Javascript
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.secure == false and self.secure) {
|
||||
// secure cookie can only be sent over HTTPs
|
||||
return false;
|
||||
}
|
||||
|
||||
if (same_site == false) {
|
||||
// If we aren't on the "same site" (matching 2nd level domain
|
||||
// taking into account public suffix list), then the cookie
|
||||
// can only be sent if cookie.same_site == .none, or if
|
||||
// we're navigating to (as opposed to, say, loading an image)
|
||||
// and cookie.same_site == .lax
|
||||
switch (self.same_site) {
|
||||
.strict => return false,
|
||||
.lax => if (navigation == false) return false,
|
||||
.none => {},
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (self.domain[0] == '.') {
|
||||
// When a Set-Cookie header has a Domain attribute
|
||||
// Then we will _always_ prefix it with a dot, extending its
|
||||
// availability to all subdomains (yes, setting the Domain
|
||||
// attributes EXPANDS the domains which the cookie will be
|
||||
// sent to, to always include all subdomains).
|
||||
if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) {
|
||||
return false;
|
||||
}
|
||||
} else if (std.mem.eql(u8, url.host, self.domain) == false) {
|
||||
// When the Domain attribute isn't specific, then the cookie
|
||||
// is only sent on an exact match.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if (self.path[self.path.len - 1] == '/') {
|
||||
// If our cookie has a trailing slash, we can only match is
|
||||
// the target path is a perfix. I.e., if our path is
|
||||
// /doc/ we can only match /doc/*
|
||||
if (std.mem.startsWith(u8, url.path, self.path) == false) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Our cookie path is something like /hello
|
||||
if (std.mem.startsWith(u8, url.path, self.path) == false) {
|
||||
// The target path has to either be /hello (it isn't)
|
||||
return false;
|
||||
} else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) {
|
||||
// Or it has to be something like /hello/* (it isn't)
|
||||
// it isn't!
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
pub const PreparedUri = struct {
|
||||
host: []const u8, // Percent encoded, lower case
|
||||
path: []const u8, // Percent encoded
|
||||
secure: bool, // True if scheme is https
|
||||
};
|
||||
|
||||
fn trim(str: []const u8) []const u8 {
|
||||
return std.mem.trim(u8, str, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
fn trimLeft(str: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
fn trimRight(str: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, str, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
pub fn toLower(str: []u8) []u8 {
|
||||
for (str, 0..) |c, i| {
|
||||
str[i] = std.ascii.toLower(c);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "cookie: findSecondLevelDomain" {
|
||||
const cases = [_]struct { []const u8, []const u8 }{
|
||||
.{ "", "" },
|
||||
.{ "com", "com" },
|
||||
.{ "lightpanda.io", "lightpanda.io" },
|
||||
.{ "lightpanda.io", "test.lightpanda.io" },
|
||||
.{ "lightpanda.io", "first.test.lightpanda.io" },
|
||||
.{ "www.gov.uk", "www.gov.uk" },
|
||||
.{ "stats.gov.uk", "www.stats.gov.uk" },
|
||||
.{ "api.gov.uk", "api.gov.uk" },
|
||||
.{ "dev.api.gov.uk", "dev.api.gov.uk" },
|
||||
.{ "dev.api.gov.uk", "1.dev.api.gov.uk" },
|
||||
};
|
||||
for (cases) |c| {
|
||||
try testing.expectEqual(c.@"0", findSecondLevelDomain(c.@"1"));
|
||||
}
|
||||
}
|
||||
|
||||
test "Jar: add" {
|
||||
const expectCookies = struct {
|
||||
fn expect(expected: []const struct { []const u8, []const u8 }, jar: Jar) !void {
|
||||
try testing.expectEqual(expected.len, jar.cookies.items.len);
|
||||
LOOP: for (expected) |e| {
|
||||
for (jar.cookies.items) |c| {
|
||||
if (std.mem.eql(u8, e.@"0", c.name) and std.mem.eql(u8, e.@"1", c.value)) {
|
||||
continue :LOOP;
|
||||
}
|
||||
}
|
||||
std.debug.print("Cookie ({s}={s}) not found", .{ e.@"0", e.@"1" });
|
||||
return error.CookieNotFound;
|
||||
}
|
||||
}
|
||||
}.expect;
|
||||
|
||||
const now = std.time.timestamp();
|
||||
|
||||
var jar = Jar.init(testing.allocator);
|
||||
defer jar.deinit();
|
||||
try expectCookies(&.{}, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000;Max-Age=0"), now);
|
||||
try expectCookies(&.{}, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000"), now);
|
||||
try expectCookies(&.{.{ "over", "9000" }}, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9000!!"), now);
|
||||
try expectCookies(&.{.{ "over", "9000!!" }}, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flow"), now);
|
||||
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flow" } }, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "spice=flows;Path=/"), now);
|
||||
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" } }, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9001;Path=/other"), now);
|
||||
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" } }, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=9002;Path=/;Domain=lightpanda.io"), now);
|
||||
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9001" }, .{ "over", "9002" } }, jar);
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "over=x;Path=/other;Max-Age=-200"), now);
|
||||
try expectCookies(&.{ .{ "over", "9000!!" }, .{ "spice", "flows" }, .{ "over", "9002" } }, jar);
|
||||
}
|
||||
|
||||
test "Jar: forRequest" {
|
||||
const expectCookies = struct {
|
||||
fn expect(expected: []const u8, jar: *Jar, target_uri: Uri, opts: LookupOpts) !void {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .{};
|
||||
defer arr.deinit(testing.allocator);
|
||||
try jar.forRequest(&target_uri, arr.writer(testing.allocator), opts);
|
||||
try testing.expectEqual(expected, arr.items);
|
||||
}
|
||||
}.expect;
|
||||
|
||||
const now = std.time.timestamp();
|
||||
|
||||
var jar = Jar.init(testing.allocator);
|
||||
defer jar.deinit();
|
||||
|
||||
const test_uri_2 = Uri.parse("http://test.lightpanda.io/") catch unreachable;
|
||||
|
||||
{
|
||||
// test with no cookies
|
||||
try expectCookies("", &jar, test_uri, .{ .is_http = true });
|
||||
}
|
||||
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global2=2;Max-Age=30;domain=lightpanda.io"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path1=3;Path=/about"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "path2=4;Path=/docs/"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "secure=5;Secure"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitenone=6;SameSite=None;Path=/x/;Secure"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitelax=7;SameSite=Lax;Path=/x/"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri, "sitestrict=8;SameSite=Strict;Path=/x/"), now);
|
||||
try jar.add(try Cookie.parse(testing.allocator, &test_uri_2, "domain1=9;domain=test.lightpanda.io"), now);
|
||||
|
||||
// nothing fancy here
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .is_http = true });
|
||||
try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false, .is_http = true });
|
||||
|
||||
// We have a cookie where Domain=lightpanda.io
|
||||
// This should _not_ match xyxlightpanda.io
|
||||
try expectCookies("", &jar, try std.Uri.parse("http://anothersitelightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// matching path without trailing /
|
||||
try expectCookies("global1=1; global2=2; path1=3", &jar, try std.Uri.parse("http://lightpanda.io/about"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// incomplete prefix path
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// path doesn't match
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// path doesn't match cookie directory
|
||||
try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/docs"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// exact directory match
|
||||
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// sub directory match
|
||||
try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/more"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// secure
|
||||
try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// navigational cross domain, secure
|
||||
try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// navigational cross domain, insecure
|
||||
try expectCookies("global1=1; global2=2; sitelax=7", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational cross domain, insecure
|
||||
try expectCookies("", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational cross domain, secure
|
||||
try expectCookies("sitenone=6", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://example.com/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-navigational same origin
|
||||
try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{
|
||||
.origin_uri = &(try std.Uri.parse("https://lightpanda.io/")),
|
||||
.navigation = false,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// exact domain match + suffix
|
||||
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// domain suffix match + suffix
|
||||
try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
// non-matching domain
|
||||
try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
|
||||
const l = jar.cookies.items.len;
|
||||
try expectCookies("global1=1", &jar, test_uri, .{
|
||||
.request_time = now + 100,
|
||||
.origin_uri = &test_uri,
|
||||
.is_http = true,
|
||||
});
|
||||
try testing.expectEqual(l - 1, jar.cookies.items.len);
|
||||
|
||||
// If you add more cases after this point, note that the above test removes
|
||||
// the 'global2' cookie
|
||||
}
|
||||
|
||||
test "Cookie: parse key=value" {
|
||||
try expectError(error.Empty, null, "");
|
||||
try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' });
|
||||
try expectError(error.InvalidByteSequence, null, &.{ 'a', 127, '=', 'b' });
|
||||
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 20 });
|
||||
try expectError(error.InvalidByteSequence, null, &.{ 'a', '=', 'b', 128 });
|
||||
|
||||
try expectAttribute(.{ .name = "", .value = "a" }, null, "a");
|
||||
try expectAttribute(.{ .name = "", .value = "a" }, null, "a;");
|
||||
try expectAttribute(.{ .name = "", .value = "a b" }, null, "a b");
|
||||
try expectAttribute(.{ .name = "a b", .value = "b" }, null, "a b=b");
|
||||
try expectAttribute(.{ .name = "a,", .value = "b" }, null, "a,=b");
|
||||
try expectAttribute(.{ .name = ":a>", .value = "b>><" }, null, ":a>=b>><");
|
||||
|
||||
try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc=");
|
||||
try expectAttribute(.{ .name = "abc", .value = "" }, null, "abc=;");
|
||||
|
||||
try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b");
|
||||
try expectAttribute(.{ .name = "a", .value = "b" }, null, "a=b;");
|
||||
|
||||
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f");
|
||||
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f ");
|
||||
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f;");
|
||||
try expectAttribute(.{ .name = "abc", .value = "fe f" }, null, "abc= fe f ;");
|
||||
try expectAttribute(.{ .name = "abc", .value = "\" fe f\"" }, null, "abc=\" fe f\"");
|
||||
try expectAttribute(.{ .name = "abc", .value = "\" fe f \"" }, null, "abc=\" fe f \"");
|
||||
try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c=1ads23 ");
|
||||
|
||||
try expectAttribute(.{ .name = "ab4344c", .value = "1ads23" }, null, " ab4344c = 1ads23 ;");
|
||||
}
|
||||
|
||||
test "Cookie: parse path" {
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/", "b");
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/", "b;path");
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path=");
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/", "b;Path=;");
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/", "b; Path=other");
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/23", "b; path=other ");
|
||||
|
||||
try expectAttribute(.{ .path = "/" }, "http://a/abc", "b");
|
||||
try expectAttribute(.{ .path = "/abc" }, "http://a/abc/", "b");
|
||||
try expectAttribute(.{ .path = "/abc" }, "http://a/abc/123", "b");
|
||||
try expectAttribute(.{ .path = "/abc/123" }, "http://a/abc/123/", "b");
|
||||
|
||||
try expectAttribute(.{ .path = "/a" }, "http://a/", "b;Path=/a");
|
||||
try expectAttribute(.{ .path = "/aa" }, "http://a/", "b;path=/aa;");
|
||||
try expectAttribute(.{ .path = "/aabc/" }, "http://a/", "b; path= /aabc/ ;");
|
||||
|
||||
try expectAttribute(.{ .path = "/bbb/" }, "http://a/", "b; path=/a/; path=/bbb/");
|
||||
try expectAttribute(.{ .path = "/cc" }, "http://a/", "b; path=/a/; path=/bbb/; path = /cc");
|
||||
}
|
||||
|
||||
test "Cookie: parse secure" {
|
||||
try expectAttribute(.{ .secure = false }, null, "b");
|
||||
try expectAttribute(.{ .secure = false }, null, "b;secured");
|
||||
try expectAttribute(.{ .secure = false }, null, "b;security");
|
||||
try expectAttribute(.{ .secure = false }, null, "b;SecureX");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; Secure");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; Secure ");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; Secure=on ");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; Secure=Off ");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; secure=Off ");
|
||||
try expectAttribute(.{ .secure = true }, null, "b; seCUre=Off ");
|
||||
}
|
||||
|
||||
test "Cookie: parse HttpOnly" {
|
||||
try expectAttribute(.{ .http_only = false }, null, "b");
|
||||
try expectAttribute(.{ .http_only = false }, null, "b;HttpOnly0");
|
||||
try expectAttribute(.{ .http_only = false }, null, "b;H ttpOnly");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; Httponly ");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; Httponly=on ");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off ");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; httpOnly=Off ");
|
||||
try expectAttribute(.{ .http_only = true }, null, "b; HttpOnly=Off ");
|
||||
}
|
||||
|
||||
test "Cookie: parse SameSite" {
|
||||
try expectAttribute(.{ .same_site = .lax }, null, "b;samesite");
|
||||
try expectAttribute(.{ .same_site = .lax }, null, "b;samesite=lax");
|
||||
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Lax ");
|
||||
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Other ");
|
||||
try expectAttribute(.{ .same_site = .lax }, null, "b; SameSite=Nope ");
|
||||
|
||||
// SameSite=none is only valid when Secure is set. The whole cookie is
|
||||
// rejected otherwise
|
||||
try expectError(error.InsecureSameSite, null, "b;samesite=none");
|
||||
try expectError(error.InsecureSameSite, null, "b;SameSite=None");
|
||||
try expectAttribute(.{ .same_site = .none }, null, "b; samesite=none; secure ");
|
||||
try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None ; SECURE");
|
||||
try expectAttribute(.{ .same_site = .none }, null, "b;Secure; SameSite=None");
|
||||
try expectAttribute(.{ .same_site = .none }, null, "b; SameSite=None; Secure");
|
||||
|
||||
try expectAttribute(.{ .same_site = .strict }, null, "b; samesite=Strict ");
|
||||
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite= STRICT ");
|
||||
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSITE=strict;");
|
||||
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=Strict");
|
||||
|
||||
try expectAttribute(.{ .same_site = .strict }, null, "b; SameSite=None; SameSite=lax; SameSite=Strict");
|
||||
}
|
||||
|
||||
test "Cookie: parse max-age" {
|
||||
try expectAttribute(.{ .expires = null }, null, "b;max-age");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;max-age=abc");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;max-age=13.22");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;max-age=13abc");
|
||||
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 13 }, null, "b;max-age=13");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + -22 }, null, "b;max-age=-22");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 4294967296 }, null, "b;max-age=4294967296");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + -4294967296 }, null, "b;Max-Age= -4294967296");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 0 }, null, "b; Max-Age=0");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 500 }, null, "b; Max-Age = 500 ; Max-Age=invalid");
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 1000 }, null, "b;max-age=600;max-age=0;max-age = 1000");
|
||||
}
|
||||
|
||||
test "Cookie: parse expires" {
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=abc");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=13.22");
|
||||
try expectAttribute(.{ .expires = null }, null, "b;expires=33");
|
||||
|
||||
try expectAttribute(.{ .expires = 1918798080 }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT");
|
||||
try expectAttribute(.{ .expires = 1784275395 }, null, "b;expires=Fri, 17-Jul-2026 08:03:15 GMT");
|
||||
// max-age has priority over expires
|
||||
try expectAttribute(.{ .expires = std.time.timestamp() + 10 }, null, "b;Max-Age=10; expires=Wed, 21 Oct 2030 07:28:00 GMT");
|
||||
}
|
||||
|
||||
test "Cookie: parse all" {
|
||||
try expectCookie(.{
|
||||
.name = "user-id",
|
||||
.value = "9000",
|
||||
.path = "/cms",
|
||||
.domain = "lightpanda.io",
|
||||
}, "https://lightpanda.io/cms/users", "user-id=9000");
|
||||
|
||||
try expectCookie(.{
|
||||
.name = "user-id",
|
||||
.value = "9000",
|
||||
.path = "/",
|
||||
.http_only = true,
|
||||
.secure = true,
|
||||
.domain = ".lightpanda.io",
|
||||
.expires = @floatFromInt(std.time.timestamp() + 30),
|
||||
}, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io");
|
||||
|
||||
try expectCookie(.{
|
||||
.name = "app_session",
|
||||
.value = "123",
|
||||
.path = "/",
|
||||
.http_only = true,
|
||||
.secure = false,
|
||||
.domain = ".localhost",
|
||||
.same_site = .lax,
|
||||
.expires = @floatFromInt(std.time.timestamp() + 7200),
|
||||
}, "http://localhost:8000/login", "app_session=123; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax");
|
||||
}
|
||||
|
||||
test "Cookie: parse domain" {
|
||||
try expectAttribute(.{ .domain = "lightpanda.io" }, "http://lightpanda.io/", "b");
|
||||
try expectAttribute(.{ .domain = "dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b");
|
||||
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=lightpanda.io");
|
||||
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://lightpanda.io/", "b;domain=.lightpanda.io");
|
||||
try expectAttribute(.{ .domain = ".dev.lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=dev.lightpanda.io");
|
||||
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=lightpanda.io");
|
||||
try expectAttribute(.{ .domain = ".lightpanda.io" }, "http://dev.lightpanda.io/", "b;domain=.lightpanda.io");
|
||||
try expectAttribute(.{ .domain = ".localhost" }, "http://localhost/", "b;domain=localhost");
|
||||
try expectAttribute(.{ .domain = ".localhost" }, "http://localhost/", "b;domain=.localhost");
|
||||
|
||||
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=io");
|
||||
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=.io");
|
||||
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.io");
|
||||
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.lightpanda.com");
|
||||
try expectError(error.InvalidDomain, "http://lightpanda.io/", "b;domain=other.example.com");
|
||||
}
|
||||
|
||||
const ExpectedCookie = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
path: []const u8,
|
||||
domain: []const u8,
|
||||
expires: ?f64 = null,
|
||||
secure: bool = false,
|
||||
http_only: bool = false,
|
||||
same_site: Cookie.SameSite = .lax,
|
||||
};
|
||||
|
||||
fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u8) !void {
|
||||
const uri = try Uri.parse(url);
|
||||
var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie);
|
||||
defer cookie.deinit();
|
||||
|
||||
try testing.expectEqual(expected.name, cookie.name);
|
||||
try testing.expectEqual(expected.value, cookie.value);
|
||||
try testing.expectEqual(expected.secure, cookie.secure);
|
||||
try testing.expectEqual(expected.http_only, cookie.http_only);
|
||||
try testing.expectEqual(expected.same_site, cookie.same_site);
|
||||
try testing.expectEqual(expected.path, cookie.path);
|
||||
try testing.expectEqual(expected.domain, cookie.domain);
|
||||
|
||||
try testing.expectDelta(expected.expires, cookie.expires, 2.0);
|
||||
}
|
||||
|
||||
fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void {
|
||||
const uri = if (url) |u| try Uri.parse(u) else test_uri;
|
||||
var cookie = try Cookie.parse(testing.allocator, &uri, set_cookie);
|
||||
defer cookie.deinit();
|
||||
|
||||
inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| {
|
||||
if (comptime std.mem.eql(u8, f.name, "expires")) {
|
||||
switch (@typeInfo(@TypeOf(expected.expires))) {
|
||||
.int, .comptime_int => try testing.expectDelta(@as(f64, @floatFromInt(expected.expires)), cookie.expires, 1.0),
|
||||
else => try testing.expectDelta(expected.expires, cookie.expires, 1.0),
|
||||
}
|
||||
} else {
|
||||
try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expectError(expected: anyerror, url: ?[]const u8, set_cookie: []const u8) !void {
|
||||
const uri = if (url) |u| try Uri.parse(u) else test_uri;
|
||||
try testing.expectError(expected, Cookie.parse(testing.allocator, &uri, set_cookie));
|
||||
}
|
||||
|
||||
const test_uri = Uri.parse("http://lightpanda.io/") catch unreachable;
|
||||
@@ -1,646 +0,0 @@
|
||||
// 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 Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
const FormData = @import("../xhr/form_data.zig").FormData;
|
||||
const HTMLElement = @import("../html/elements.zig").HTMLElement;
|
||||
|
||||
const kv = @import("../key_value.zig");
|
||||
const iterator = @import("../iterator/iterator.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
URL,
|
||||
URLSearchParams,
|
||||
KeyIterable,
|
||||
ValueIterable,
|
||||
EntryIterable,
|
||||
};
|
||||
|
||||
// https://url.spec.whatwg.org/#url
|
||||
//
|
||||
// TODO we could avoid many of these getter string allocatoration in two differents
|
||||
// way:
|
||||
//
|
||||
// 1. We can eventually get the slice of scheme *with* the following char in
|
||||
// the underlying string. But I don't know if it's possible and how to do that.
|
||||
// I mean, if the rawuri contains `https://foo.bar`, uri.scheme is a slice
|
||||
// containing only `https`. I want `https:` so, in theory, I don't need to
|
||||
// allocatorate data, I should be able to retrieve the scheme + the following `:`
|
||||
// from rawuri.
|
||||
//
|
||||
// 2. The other way would be to copy the `std.Uri` code to have a dedicated
|
||||
// parser including the characters we want for the web API.
|
||||
pub const URL = struct {
|
||||
uri: std.Uri,
|
||||
search_params: URLSearchParams,
|
||||
|
||||
pub const empty = URL{
|
||||
.uri = .{ .scheme = "" },
|
||||
.search_params = .{},
|
||||
};
|
||||
|
||||
const URLArg = union(enum) {
|
||||
url: *URL,
|
||||
element: *parser.ElementHTML,
|
||||
string: []const u8,
|
||||
|
||||
fn toString(self: URLArg, arena: Allocator) !?[]const u8 {
|
||||
switch (self) {
|
||||
.string => |s| return s,
|
||||
.url => |url| return try url.toString(arena),
|
||||
.element => |e| return try parser.elementGetAttribute(@ptrCast(e), "href"),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn constructor(url: URLArg, base: ?URLArg, page: *Page) !URL {
|
||||
const arena = page.arena;
|
||||
const url_str = try url.toString(arena) orelse return error.InvalidArgument;
|
||||
|
||||
var raw: ?[]const u8 = null;
|
||||
if (base) |b| {
|
||||
if (try b.toString(arena)) |bb| {
|
||||
raw = try @import("../../url.zig").URL.stitch(arena, url_str, bb, .{});
|
||||
}
|
||||
}
|
||||
|
||||
if (raw == null) {
|
||||
// if it was a URL, then it's already be owned by the arena
|
||||
raw = if (url == .url) url_str else try arena.dupe(u8, url_str);
|
||||
}
|
||||
|
||||
const uri = std.Uri.parse(raw.?) catch return error.TypeError;
|
||||
return init(arena, uri);
|
||||
}
|
||||
|
||||
pub fn init(arena: Allocator, uri: std.Uri) !URL {
|
||||
return .{
|
||||
.uri = uri,
|
||||
.search_params = try URLSearchParams.init(
|
||||
arena,
|
||||
uriComponentNullStr(uri.query),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_origin(self: *URL, page: *Page) ![]const u8 {
|
||||
var buf = std.ArrayList(u8).init(page.arena);
|
||||
try self.uri.writeToStream(.{
|
||||
.scheme = true,
|
||||
.authentication = false,
|
||||
.authority = true,
|
||||
.path = false,
|
||||
.query = false,
|
||||
.fragment = false,
|
||||
}, buf.writer());
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
// get_href returns the URL by writing all its components.
|
||||
pub fn get_href(self: *URL, page: *Page) ![]const u8 {
|
||||
return self.toString(page.arena);
|
||||
}
|
||||
|
||||
pub fn _toString(self: *URL, page: *Page) ![]const u8 {
|
||||
return self.toString(page.arena);
|
||||
}
|
||||
|
||||
// format the url with all its components.
|
||||
pub fn toString(self: *const URL, arena: Allocator) ![]const u8 {
|
||||
var buf: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try self.uri.writeToStream(.{
|
||||
.scheme = true,
|
||||
.authentication = true,
|
||||
.authority = true,
|
||||
.path = uriComponentNullStr(self.uri.path).len > 0,
|
||||
}, buf.writer(arena));
|
||||
|
||||
if (self.search_params.get_size() > 0) {
|
||||
try buf.append(arena, '?');
|
||||
try self.search_params.write(buf.writer(arena));
|
||||
}
|
||||
|
||||
{
|
||||
const fragment = uriComponentNullStr(self.uri.fragment);
|
||||
if (fragment.len > 0) {
|
||||
try buf.append(arena, '#');
|
||||
try buf.appendSlice(arena, fragment);
|
||||
}
|
||||
}
|
||||
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn get_protocol(self: *URL, page: *Page) ![]const u8 {
|
||||
return try std.mem.concat(page.arena, u8, &[_][]const u8{ self.uri.scheme, ":" });
|
||||
}
|
||||
|
||||
pub fn get_username(self: *URL) []const u8 {
|
||||
return uriComponentNullStr(self.uri.user);
|
||||
}
|
||||
|
||||
pub fn get_password(self: *URL) []const u8 {
|
||||
return uriComponentNullStr(self.uri.password);
|
||||
}
|
||||
|
||||
pub fn get_host(self: *URL, page: *Page) ![]const u8 {
|
||||
var buf = std.ArrayList(u8).init(page.arena);
|
||||
|
||||
try self.uri.writeToStream(.{
|
||||
.scheme = false,
|
||||
.authentication = false,
|
||||
.authority = true,
|
||||
.path = false,
|
||||
.query = false,
|
||||
.fragment = false,
|
||||
}, buf.writer());
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn get_hostname(self: *URL) []const u8 {
|
||||
return uriComponentNullStr(self.uri.host);
|
||||
}
|
||||
|
||||
pub fn get_port(self: *URL, page: *Page) ![]const u8 {
|
||||
const arena = page.arena;
|
||||
if (self.uri.port == null) return try arena.dupe(u8, "");
|
||||
|
||||
var buf = std.ArrayList(u8).init(arena);
|
||||
try std.fmt.formatInt(self.uri.port.?, 10, .lower, .{}, buf.writer());
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn get_pathname(self: *URL) []const u8 {
|
||||
if (uriComponentStr(self.uri.path).len == 0) return "/";
|
||||
return uriComponentStr(self.uri.path);
|
||||
}
|
||||
|
||||
pub fn get_search(self: *URL, page: *Page) ![]const u8 {
|
||||
const arena = page.arena;
|
||||
|
||||
if (self.search_params.get_size() == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
var buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
try buf.append(arena, '?');
|
||||
try self.search_params.encode(buf.writer(arena));
|
||||
return buf.items;
|
||||
}
|
||||
|
||||
pub fn set_search(self: *URL, qs_: ?[]const u8, page: *Page) !void {
|
||||
self.search_params = .{};
|
||||
if (qs_) |qs| {
|
||||
self.search_params = try URLSearchParams.init(page.arena, qs);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_hash(self: *URL, page: *Page) ![]const u8 {
|
||||
const arena = page.arena;
|
||||
if (self.uri.fragment == null) return try arena.dupe(u8, "");
|
||||
|
||||
return try std.mem.concat(arena, u8, &[_][]const u8{ "#", uriComponentNullStr(self.uri.fragment) });
|
||||
}
|
||||
|
||||
pub fn get_searchParams(self: *URL) *URLSearchParams {
|
||||
return &self.search_params;
|
||||
}
|
||||
|
||||
pub fn _toJSON(self: *URL, page: *Page) ![]const u8 {
|
||||
return self.get_href(page);
|
||||
}
|
||||
};
|
||||
|
||||
// uriComponentNullStr converts an optional std.Uri.Component to string value.
|
||||
// The string value can be undecoded.
|
||||
fn uriComponentNullStr(c: ?std.Uri.Component) []const u8 {
|
||||
if (c == null) return "";
|
||||
|
||||
return uriComponentStr(c.?);
|
||||
}
|
||||
|
||||
fn uriComponentStr(c: std.Uri.Component) []const u8 {
|
||||
return switch (c) {
|
||||
.raw => |v| v,
|
||||
.percent_encoded => |v| v,
|
||||
};
|
||||
}
|
||||
|
||||
// https://url.spec.whatwg.org/#interface-urlsearchparams
|
||||
pub const URLSearchParams = struct {
|
||||
entries: kv.List = .{},
|
||||
|
||||
const URLSearchParamsOpts = union(enum) {
|
||||
qs: []const u8,
|
||||
form_data: *const FormData,
|
||||
js_obj: Env.JsObject,
|
||||
};
|
||||
pub fn constructor(opts_: ?URLSearchParamsOpts, page: *Page) !URLSearchParams {
|
||||
const opts = opts_ orelse return .{ .entries = .{} };
|
||||
return switch (opts) {
|
||||
.qs => |qs| init(page.arena, qs),
|
||||
.form_data => |fd| .{ .entries = try fd.entries.clone(page.arena) },
|
||||
.js_obj => |js_obj| {
|
||||
const arena = page.arena;
|
||||
var it = js_obj.nameIterator();
|
||||
|
||||
var entries: kv.List = .{};
|
||||
try entries.ensureTotalCapacity(arena, it.count);
|
||||
|
||||
while (try it.next()) |js_name| {
|
||||
const name = try js_name.toString(arena);
|
||||
const js_val = try js_obj.get(name);
|
||||
entries.appendOwnedAssumeCapacity(
|
||||
name,
|
||||
try js_val.toString(arena),
|
||||
);
|
||||
}
|
||||
|
||||
return .{ .entries = entries };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn init(arena: Allocator, qs_: ?[]const u8) !URLSearchParams {
|
||||
return .{
|
||||
.entries = if (qs_) |qs| try parseQuery(arena, qs) else .{},
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_size(self: *const URLSearchParams) u32 {
|
||||
return @intCast(self.entries.count());
|
||||
}
|
||||
|
||||
pub fn _append(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void {
|
||||
return self.entries.append(page.arena, name, value);
|
||||
}
|
||||
|
||||
pub fn _set(self: *URLSearchParams, name: []const u8, value: []const u8, page: *Page) !void {
|
||||
return self.entries.set(page.arena, name, value);
|
||||
}
|
||||
|
||||
pub fn _delete(self: *URLSearchParams, name: []const u8, value_: ?[]const u8) void {
|
||||
if (value_) |value| {
|
||||
return self.entries.deleteKeyValue(name, value);
|
||||
}
|
||||
return self.entries.delete(name);
|
||||
}
|
||||
|
||||
pub fn _get(self: *const URLSearchParams, name: []const u8) ?[]const u8 {
|
||||
return self.entries.get(name);
|
||||
}
|
||||
|
||||
pub fn _getAll(self: *const URLSearchParams, name: []const u8, page: *Page) ![]const []const u8 {
|
||||
return self.entries.getAll(page.call_arena, name);
|
||||
}
|
||||
|
||||
pub fn _has(self: *const URLSearchParams, name: []const u8) bool {
|
||||
return self.entries.has(name);
|
||||
}
|
||||
|
||||
pub fn _keys(self: *const URLSearchParams) KeyIterable {
|
||||
return .{ .inner = self.entries.keyIterator() };
|
||||
}
|
||||
|
||||
pub fn _values(self: *const URLSearchParams) ValueIterable {
|
||||
return .{ .inner = self.entries.valueIterator() };
|
||||
}
|
||||
|
||||
pub fn _entries(self: *const URLSearchParams) EntryIterable {
|
||||
return .{ .inner = self.entries.entryIterator() };
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *const URLSearchParams) EntryIterable {
|
||||
return self._entries();
|
||||
}
|
||||
|
||||
pub fn _toString(self: *const URLSearchParams, page: *Page) ![]const u8 {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .empty;
|
||||
try self.write(arr.writer(page.call_arena));
|
||||
return arr.items;
|
||||
}
|
||||
|
||||
fn write(self: *const URLSearchParams, writer: anytype) !void {
|
||||
return kv.urlEncode(self.entries, .query, writer);
|
||||
}
|
||||
|
||||
// TODO
|
||||
pub fn _sort(_: *URLSearchParams) void {}
|
||||
|
||||
fn encode(self: *const URLSearchParams, writer: anytype) !void {
|
||||
return kv.urlEncode(self.entries, .query, writer);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse the given query.
|
||||
fn parseQuery(arena: Allocator, s: []const u8) !kv.List {
|
||||
var list = kv.List{};
|
||||
|
||||
const ln = s.len;
|
||||
if (ln == 0) {
|
||||
return list;
|
||||
}
|
||||
|
||||
var query = if (s[0] == '?') s[1..] else s;
|
||||
while (query.len > 0) {
|
||||
const i = std.mem.indexOfScalarPos(u8, query, 0, '=') orelse query.len;
|
||||
const name = query[0..i];
|
||||
|
||||
var value: ?[]const u8 = null;
|
||||
if (i < query.len) {
|
||||
query = query[i + 1 ..];
|
||||
const j = std.mem.indexOfScalarPos(u8, query, 0, '&') orelse query.len;
|
||||
value = query[0..j];
|
||||
|
||||
query = if (j < query.len) query[j + 1 ..] else "";
|
||||
} else {
|
||||
query = "";
|
||||
}
|
||||
|
||||
try list.appendOwned(
|
||||
arena,
|
||||
try unescape(arena, name),
|
||||
if (value) |v| try unescape(arena, v) else "",
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
fn unescape(arena: Allocator, input: []const u8) ![]const u8 {
|
||||
const HEX_CHAR = comptime blk: {
|
||||
var all = std.mem.zeroes([256]bool);
|
||||
for ('a'..('f' + 1)) |b| all[b] = true;
|
||||
for ('A'..('F' + 1)) |b| all[b] = true;
|
||||
for ('0'..('9' + 1)) |b| all[b] = true;
|
||||
break :blk all;
|
||||
};
|
||||
|
||||
const HEX_DECODE = comptime blk: {
|
||||
var all = std.mem.zeroes([256]u8);
|
||||
for ('a'..('z' + 1)) |b| all[b] = b - 'a' + 10;
|
||||
for ('A'..('Z' + 1)) |b| all[b] = b - 'A' + 10;
|
||||
for ('0'..('9' + 1)) |b| all[b] = b - '0';
|
||||
break :blk all;
|
||||
};
|
||||
|
||||
var has_plus = false;
|
||||
var unescaped_len = input.len;
|
||||
|
||||
{
|
||||
// Figure out if we have any spaces and what the final unescaped length
|
||||
// will be (which will let us know if we have anything to unescape in
|
||||
// the first place)
|
||||
var i: usize = 0;
|
||||
while (i < input.len) {
|
||||
const c = input[i];
|
||||
if (c == '%') {
|
||||
if (i + 2 >= input.len or !HEX_CHAR[input[i + 1]] or !HEX_CHAR[input[i + 2]]) {
|
||||
return error.EscapeError;
|
||||
}
|
||||
i += 3;
|
||||
unescaped_len -= 2;
|
||||
} else if (c == '+') {
|
||||
has_plus = true;
|
||||
i += 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// no encoding, and no plus. nothing to unescape
|
||||
if (unescaped_len == input.len and has_plus == false) {
|
||||
// we always dupe, because we know our caller wants it always duped.
|
||||
return arena.dupe(u8, input);
|
||||
}
|
||||
|
||||
var unescaped = try arena.alloc(u8, unescaped_len);
|
||||
errdefer arena.free(unescaped);
|
||||
|
||||
var input_pos: usize = 0;
|
||||
for (0..unescaped_len) |unescaped_pos| {
|
||||
switch (input[input_pos]) {
|
||||
'+' => {
|
||||
unescaped[unescaped_pos] = ' ';
|
||||
input_pos += 1;
|
||||
},
|
||||
'%' => {
|
||||
const encoded = input[input_pos + 1 .. input_pos + 3];
|
||||
const encoded_as_uint = @as(u16, @bitCast(encoded[0..2].*));
|
||||
unescaped[unescaped_pos] = switch (encoded_as_uint) {
|
||||
asUint(u16, "20") => ' ',
|
||||
asUint(u16, "21") => '!',
|
||||
asUint(u16, "22") => '"',
|
||||
asUint(u16, "23") => '#',
|
||||
asUint(u16, "24") => '$',
|
||||
asUint(u16, "25") => '%',
|
||||
asUint(u16, "26") => '&',
|
||||
asUint(u16, "27") => '\'',
|
||||
asUint(u16, "28") => '(',
|
||||
asUint(u16, "29") => ')',
|
||||
asUint(u16, "2A") => '*',
|
||||
asUint(u16, "2B") => '+',
|
||||
asUint(u16, "2C") => ',',
|
||||
asUint(u16, "2F") => '/',
|
||||
asUint(u16, "3A") => ':',
|
||||
asUint(u16, "3B") => ';',
|
||||
asUint(u16, "3D") => '=',
|
||||
asUint(u16, "3F") => '?',
|
||||
asUint(u16, "40") => '@',
|
||||
asUint(u16, "5B") => '[',
|
||||
asUint(u16, "5D") => ']',
|
||||
else => HEX_DECODE[encoded[0]] << 4 | HEX_DECODE[encoded[1]],
|
||||
};
|
||||
input_pos += 3;
|
||||
},
|
||||
else => |c| {
|
||||
unescaped[unescaped_pos] = c;
|
||||
input_pos += 1;
|
||||
},
|
||||
}
|
||||
}
|
||||
return unescaped;
|
||||
}
|
||||
|
||||
fn asUint(comptime T: type, comptime string: []const u8) T {
|
||||
return @bitCast(string[0..string.len].*);
|
||||
}
|
||||
|
||||
const KeyIterable = iterator.Iterable(kv.KeyIterator, "URLSearchParamsKeyIterator");
|
||||
const ValueIterable = iterator.Iterable(kv.ValueIterator, "URLSearchParamsValueIterator");
|
||||
const EntryIterable = iterator.Iterable(kv.EntryIterator, "URLSearchParamsEntryIterator");
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.URL" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var url = new URL('https://foo.bar/path?query#fragment')", "undefined" },
|
||||
.{ "url.origin", "https://foo.bar" },
|
||||
.{ "url.href", "https://foo.bar/path?query#fragment" },
|
||||
.{ "url.protocol", "https:" },
|
||||
.{ "url.username", "" },
|
||||
.{ "url.password", "" },
|
||||
.{ "url.host", "foo.bar" },
|
||||
.{ "url.hostname", "foo.bar" },
|
||||
.{ "url.port", "" },
|
||||
.{ "url.pathname", "/path" },
|
||||
.{ "url.search", "?query" },
|
||||
.{ "url.hash", "#fragment" },
|
||||
.{ "url.searchParams.get('query')", "" },
|
||||
|
||||
.{ "url.search = 'hello=world'", null },
|
||||
.{ "url.searchParams.size", "1" },
|
||||
.{ "url.searchParams.get('hello')", "world" },
|
||||
|
||||
.{ "url.search = '?over=9000'", null },
|
||||
.{ "url.searchParams.size", "1" },
|
||||
.{ "url.searchParams.get('over')", "9000" },
|
||||
|
||||
.{ "url.search = ''", null },
|
||||
.{ "url.searchParams.size", "0" },
|
||||
|
||||
.{ " const url2 = new URL(url);", null },
|
||||
.{ "url2.href", "https://foo.bar/path#fragment" },
|
||||
|
||||
.{ " try { new URL(document.createElement('a')); } catch (e) { e }", "TypeError: invalid argument" },
|
||||
|
||||
.{ " let a = document.createElement('a');", null },
|
||||
.{ " a.href = 'https://www.lightpanda.io/over?9000=!!';", null },
|
||||
.{ " const url3 = new URL(a);", null },
|
||||
.{ "url3.href", "https://www.lightpanda.io/over?9000=%21%21" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var url = new URL('https://foo.bar/path?a=~&b=%7E#fragment')", "undefined" },
|
||||
.{ "url.searchParams.get('a')", "~" },
|
||||
.{ "url.searchParams.get('b')", "~" },
|
||||
.{ "url.searchParams.append('c', 'foo')", "undefined" },
|
||||
.{ "url.searchParams.get('c')", "foo" },
|
||||
.{ "url.searchParams.getAll('c').length", "1" },
|
||||
.{ "url.searchParams.getAll('c')[0]", "foo" },
|
||||
.{ "url.searchParams.size", "3" },
|
||||
|
||||
// search is dynamic
|
||||
.{ "url.search", "?a=~&b=~&c=foo" },
|
||||
// href is dynamic
|
||||
.{ "url.href", "https://foo.bar/path?a=~&b=~&c=foo#fragment" },
|
||||
|
||||
.{ "url.searchParams.delete('c', 'foo')", "undefined" },
|
||||
.{ "url.searchParams.get('c')", "null" },
|
||||
.{ "url.searchParams.delete('a')", "undefined" },
|
||||
.{ "url.searchParams.get('a')", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var url = new URL('over?9000', 'https://lightpanda.io')", null },
|
||||
.{ "url.href", "https://lightpanda.io/over?9000" },
|
||||
}, .{});
|
||||
}
|
||||
|
||||
test "Browser.URLSearchParams" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
try runner.testCases(&.{
|
||||
.{ "let usp = new URLSearchParams()", null },
|
||||
.{ "usp.get('a')", "null" },
|
||||
.{ "usp.has('a')", "false" },
|
||||
.{ "usp.getAll('a')", "" },
|
||||
.{ "usp.delete('a')", "undefined" },
|
||||
|
||||
.{ "usp.set('a', 1)", "undefined" },
|
||||
.{ "usp.has('a')", "true" },
|
||||
.{ "usp.get('a')", "1" },
|
||||
.{ "usp.getAll('a')", "1" },
|
||||
|
||||
.{ "usp.append('a', 2)", "undefined" },
|
||||
.{ "usp.has('a')", "true" },
|
||||
.{ "usp.get('a')", "1" },
|
||||
.{ "usp.getAll('a')", "1,2" },
|
||||
|
||||
.{ "usp.append('b', '3')", "undefined" },
|
||||
.{ "usp.has('a')", "true" },
|
||||
.{ "usp.get('a')", "1" },
|
||||
.{ "usp.getAll('a')", "1,2" },
|
||||
.{ "usp.has('b')", "true" },
|
||||
.{ "usp.get('b')", "3" },
|
||||
.{ "usp.getAll('b')", "3" },
|
||||
|
||||
.{ "let acc = [];", null },
|
||||
.{ "for (const key of usp.keys()) { acc.push(key) }; acc;", "a,a,b" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const value of usp.values()) { acc.push(value) }; acc;", "1,2,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of usp.entries()) { acc.push(entry) }; acc;", "a,1,a,2,b,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of usp) { acc.push(entry) }; acc;", "a,1,a,2,b,3" },
|
||||
|
||||
.{ "usp.delete('a')", "undefined" },
|
||||
.{ "usp.has('a')", "false" },
|
||||
.{ "usp.has('b')", "true" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const key of usp.keys()) { acc.push(key) }; acc;", "b" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const value of usp.values()) { acc.push(value) }; acc;", "3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of usp.entries()) { acc.push(entry) }; acc;", "b,3" },
|
||||
|
||||
.{ "acc = [];", null },
|
||||
.{ "for (const entry of usp) { acc.push(entry) }; acc;", "b,3" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "usp = new URLSearchParams('?hello')", null },
|
||||
.{ "usp.get('hello')", "" },
|
||||
|
||||
.{ "usp = new URLSearchParams('?abc=')", null },
|
||||
.{ "usp.get('abc')", "" },
|
||||
|
||||
.{ "usp = new URLSearchParams('?abc=123&')", null },
|
||||
.{ "usp.get('abc')", "123" },
|
||||
.{ "usp.size", "1" },
|
||||
|
||||
.{ "var fd = new FormData()", null },
|
||||
.{ "fd.append('a', '1')", null },
|
||||
.{ "fd.append('a', '2')", null },
|
||||
.{ "fd.append('b', '3')", null },
|
||||
.{ "ups = new URLSearchParams(fd)", null },
|
||||
.{ "ups.size", "3" },
|
||||
.{ "ups.getAll('a')", "1,2" },
|
||||
.{ "ups.getAll('b')", "3" },
|
||||
.{ "fd.delete('a')", null }, // the two aren't linked, it created a copy
|
||||
.{ "ups.size", "3" },
|
||||
.{ "ups = new URLSearchParams({over: 9000, spice: 'flow'})", null },
|
||||
.{ "ups.size", "2" },
|
||||
.{ "ups.getAll('over')", "9000" },
|
||||
.{ "ups.getAll('spice')", "flow" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
// Currently not used. Relying on polyfill instead
|
||||
|
||||
const std = @import("std");
|
||||
const log = @import("../../log.zig");
|
||||
const v8 = @import("v8");
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
const Element = @import("../dom/element.zig").Element;
|
||||
|
||||
pub const CustomElementRegistry = struct {
|
||||
// tag_name -> Function
|
||||
lookup: std.StringHashMapUnmanaged(Env.Function) = .empty,
|
||||
|
||||
pub fn _define(self: *CustomElementRegistry, tag_name: []const u8, fun: Env.Function, page: *Page) !void {
|
||||
log.info(.browser, "define custom element", .{ .name = tag_name });
|
||||
|
||||
const arena = page.arena;
|
||||
const gop = try self.lookup.getOrPut(arena, tag_name);
|
||||
if (!gop.found_existing) {
|
||||
errdefer _ = self.lookup.remove(tag_name);
|
||||
const owned_tag_name = try arena.dupe(u8, tag_name);
|
||||
gop.key_ptr.* = owned_tag_name;
|
||||
}
|
||||
gop.value_ptr.* = fun;
|
||||
fun.setName(tag_name);
|
||||
}
|
||||
|
||||
pub fn _get(self: *CustomElementRegistry, name: []const u8) ?Env.Function {
|
||||
return self.lookup.get(name);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "Browser.CustomElementRegistry" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
try runner.testCases(&.{
|
||||
// Basic registry access
|
||||
.{ "typeof customElements", "object" },
|
||||
.{ "customElements instanceof CustomElementRegistry", "true" },
|
||||
|
||||
// Define a simple custom element
|
||||
.{
|
||||
\\ class MyElement extends HTMLElement {
|
||||
\\ constructor() {
|
||||
\\ super();
|
||||
\\ this.textContent = 'Hello World';
|
||||
\\ }
|
||||
\\ }
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "customElements.define('my-element', MyElement)", "undefined" },
|
||||
|
||||
// Check if element is defined
|
||||
.{ "customElements.get('my-element') === MyElement", "true" },
|
||||
// .{ "customElements.get('non-existent')", "null" },
|
||||
|
||||
// Create element via document.createElement
|
||||
.{ "let el = document.createElement('my-element')", "undefined" },
|
||||
.{ "el instanceof MyElement", "true" },
|
||||
.{ "el instanceof HTMLElement", "true" },
|
||||
.{ "el.tagName", "MY-ELEMENT" },
|
||||
.{ "el.textContent", "Hello World" },
|
||||
|
||||
// Create element via HTML parsing
|
||||
// .{ "document.body.innerHTML = '<my-element></my-element>'", "undefined" },
|
||||
// .{ "let parsed = document.querySelector('my-element')", "undefined" },
|
||||
// .{ "parsed instanceof MyElement", "true" },
|
||||
// .{ "parsed.textContent", "Hello World" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
// 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;
|
||||
const Function = Env.Function;
|
||||
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
const EventHandler = @import("../events/event.zig").EventHandler;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
pub const XMLHttpRequestEventTarget = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
onloadstart_cbk: ?Function = null,
|
||||
onprogress_cbk: ?Function = null,
|
||||
onabort_cbk: ?Function = null,
|
||||
onload_cbk: ?Function = null,
|
||||
ontimeout_cbk: ?Function = null,
|
||||
onloadend_cbk: ?Function = null,
|
||||
onreadystatechange_cbk: ?Function = null,
|
||||
|
||||
fn register(
|
||||
self: *XMLHttpRequestEventTarget,
|
||||
alloc: std.mem.Allocator,
|
||||
typ: []const u8,
|
||||
listener: EventHandler.Listener,
|
||||
) !?Function {
|
||||
const target = @as(*parser.EventTarget, @ptrCast(self));
|
||||
|
||||
// The only time this can return null if the listener is already
|
||||
// registered. But before calling `register`, all of our functions
|
||||
// remove any existing listener, so it should be impossible to get null
|
||||
// from this function call.
|
||||
const eh = (try EventHandler.register(alloc, target, typ, listener, null)) orelse unreachable;
|
||||
return eh.callback;
|
||||
}
|
||||
|
||||
fn unregister(self: *XMLHttpRequestEventTarget, typ: []const u8, cbk_id: usize) !void {
|
||||
const et = @as(*parser.EventTarget, @ptrCast(self));
|
||||
// check if event target has already this listener
|
||||
const lst = try parser.eventTargetHasListener(et, typ, false, cbk_id);
|
||||
if (lst == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove listener
|
||||
try parser.eventTargetRemoveEventListener(et, typ, lst.?, false);
|
||||
}
|
||||
|
||||
pub fn get_onloadstart(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onloadstart_cbk;
|
||||
}
|
||||
pub fn get_onprogress(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onprogress_cbk;
|
||||
}
|
||||
pub fn get_onabort(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onabort_cbk;
|
||||
}
|
||||
pub fn get_onload(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onload_cbk;
|
||||
}
|
||||
pub fn get_ontimeout(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.ontimeout_cbk;
|
||||
}
|
||||
pub fn get_onloadend(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onloadend_cbk;
|
||||
}
|
||||
pub fn get_onreadystatechange(self: *XMLHttpRequestEventTarget) ?Function {
|
||||
return self.onreadystatechange_cbk;
|
||||
}
|
||||
|
||||
pub fn set_onloadstart(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onloadstart_cbk) |cbk| try self.unregister("loadstart", cbk.id);
|
||||
self.onloadstart_cbk = try self.register(page.arena, "loadstart", listener);
|
||||
}
|
||||
pub fn set_onprogress(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onprogress_cbk) |cbk| try self.unregister("progress", cbk.id);
|
||||
self.onprogress_cbk = try self.register(page.arena, "progress", listener);
|
||||
}
|
||||
pub fn set_onabort(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onabort_cbk) |cbk| try self.unregister("abort", cbk.id);
|
||||
self.onabort_cbk = try self.register(page.arena, "abort", listener);
|
||||
}
|
||||
pub fn set_onload(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onload_cbk) |cbk| try self.unregister("load", cbk.id);
|
||||
self.onload_cbk = try self.register(page.arena, "load", listener);
|
||||
}
|
||||
pub fn set_ontimeout(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.ontimeout_cbk) |cbk| try self.unregister("timeout", cbk.id);
|
||||
self.ontimeout_cbk = try self.register(page.arena, "timeout", listener);
|
||||
}
|
||||
pub fn set_onloadend(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onloadend_cbk) |cbk| try self.unregister("loadend", cbk.id);
|
||||
self.onloadend_cbk = try self.register(page.arena, "loadend", listener);
|
||||
}
|
||||
pub fn set_onreadystatechange(self: *XMLHttpRequestEventTarget, listener: EventHandler.Listener, page: *Page) !void {
|
||||
if (self.onreadystatechange_cbk) |cbk| try self.unregister("readystatechange", cbk.id);
|
||||
self.onreadystatechange_cbk = try self.register(page.arena, "readystatechange", listener);
|
||||
}
|
||||
};
|
||||
@@ -1,422 +0,0 @@
|
||||
// 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 log = @import("../../log.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const kv = @import("../key_value.zig");
|
||||
const iterator = @import("../iterator/iterator.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
FormData,
|
||||
KeyIterable,
|
||||
ValueIterable,
|
||||
EntryIterable,
|
||||
};
|
||||
|
||||
// https://xhr.spec.whatwg.org/#interface-formdata
|
||||
pub const FormData = struct {
|
||||
entries: kv.List,
|
||||
|
||||
pub fn constructor(form_: ?*parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
|
||||
const form = form_ orelse return .{ .entries = .{} };
|
||||
return fromForm(form, submitter_, page);
|
||||
}
|
||||
|
||||
pub fn fromForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !FormData {
|
||||
const entries = try collectForm(form, submitter_, page);
|
||||
return .{ .entries = entries };
|
||||
}
|
||||
|
||||
pub fn _get(self: *const FormData, key: []const u8) ?[]const u8 {
|
||||
return self.entries.get(key);
|
||||
}
|
||||
|
||||
pub fn _getAll(self: *const FormData, key: []const u8, page: *Page) ![]const []const u8 {
|
||||
return self.entries.getAll(page.call_arena, key);
|
||||
}
|
||||
|
||||
pub fn _has(self: *const FormData, key: []const u8) bool {
|
||||
return self.entries.has(key);
|
||||
}
|
||||
|
||||
// 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, page: *Page) !void {
|
||||
return self.entries.set(page.arena, key, value);
|
||||
}
|
||||
|
||||
// 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, page: *Page) !void {
|
||||
return self.entries.append(page.arena, key, value);
|
||||
}
|
||||
|
||||
pub fn _delete(self: *FormData, key: []const u8) void {
|
||||
return self.entries.delete(key);
|
||||
}
|
||||
|
||||
pub fn _keys(self: *const FormData) KeyIterable {
|
||||
return .{ .inner = self.entries.keyIterator() };
|
||||
}
|
||||
|
||||
pub fn _values(self: *const FormData) ValueIterable {
|
||||
return .{ .inner = self.entries.valueIterator() };
|
||||
}
|
||||
|
||||
pub fn _entries(self: *const FormData) EntryIterable {
|
||||
return .{ .inner = self.entries.entryIterator() };
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *const FormData) EntryIterable {
|
||||
return self._entries();
|
||||
}
|
||||
|
||||
pub fn write(self: *const FormData, encoding_: ?[]const u8, writer: anytype) !void {
|
||||
const encoding = encoding_ orelse {
|
||||
return kv.urlEncode(self.entries, .form, writer);
|
||||
};
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(encoding, "application/x-www-form-urlencoded")) {
|
||||
return kv.urlEncode(self.entries, .form, writer);
|
||||
}
|
||||
|
||||
log.warn(.web_api, "not implemented", .{
|
||||
.feature = "form data encoding",
|
||||
.encoding = encoding,
|
||||
});
|
||||
return error.EncodingNotSupported;
|
||||
}
|
||||
};
|
||||
|
||||
const KeyIterable = iterator.Iterable(kv.KeyIterator, "FormDataKeyIterator");
|
||||
const ValueIterable = iterator.Iterable(kv.ValueIterator, "FormDataValueIterator");
|
||||
const EntryIterable = iterator.Iterable(kv.EntryIterator, "FormDataEntryIterator");
|
||||
|
||||
// TODO: handle disabled fieldsets
|
||||
fn collectForm(form: *parser.Form, submitter_: ?*parser.ElementHTML, page: *Page) !kv.List {
|
||||
const arena = page.arena;
|
||||
|
||||
// Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements)
|
||||
// It doesn't work with dynamically added elements, because their form
|
||||
// property doesn't get set. We should fix that.
|
||||
// However, even once fixed, there are other form-collection features we
|
||||
// probably want to implement (like disabled fieldsets), so we might want
|
||||
// to stick with our own walker even if fix libdom to properly support
|
||||
// dynamically added elements.
|
||||
const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @alignCast(@ptrCast(form)), "input,select,button,textarea");
|
||||
const nodes = node_list.nodes.items;
|
||||
|
||||
var entries: kv.List = .{};
|
||||
try entries.ensureTotalCapacity(arena, nodes.len);
|
||||
|
||||
var submitter_included = false;
|
||||
const submitter_name_ = try getSubmitterName(submitter_);
|
||||
|
||||
for (nodes) |node| {
|
||||
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.inputGetType(@ptrCast(element));
|
||||
if (std.ascii.eqlIgnoreCase(tpe, "image")) {
|
||||
if (submitter_name_) |submitter_name| {
|
||||
if (std.mem.eql(u8, submitter_name, name)) {
|
||||
const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name});
|
||||
const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name});
|
||||
try entries.appendOwned(arena, key_x, "0");
|
||||
try entries.appendOwned(arena, key_y, "0");
|
||||
submitter_included = true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
submitter_included = true;
|
||||
}
|
||||
const value = try parser.inputGetValue(@ptrCast(element));
|
||||
try entries.appendOwned(arena, name, value);
|
||||
},
|
||||
.select => {
|
||||
const select: *parser.Select = @ptrCast(node);
|
||||
try collectSelectValues(arena, select, name, &entries, page);
|
||||
},
|
||||
.textarea => {
|
||||
const textarea: *parser.TextArea = @ptrCast(node);
|
||||
const value = try parser.textareaGetValue(textarea);
|
||||
try entries.appendOwned(arena, name, 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.appendOwned(arena, name, value);
|
||||
submitter_included = true;
|
||||
}
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
if (submitter_included == false) {
|
||||
if (submitter_name_) |submitter_name| {
|
||||
// this can happen if the submitter is outside the form, but associated
|
||||
// with the form via a form=ID attribute
|
||||
const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse "";
|
||||
try entries.appendOwned(arena, submitter_name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
fn collectSelectValues(arena: Allocator, select: *parser.Select, name: []const u8, entries: *kv.List, page: *Page) !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, page);
|
||||
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(@alignCast(@ptrCast(option)), "disabled") != null) {
|
||||
return;
|
||||
}
|
||||
const value = try parser.optionGetValue(option);
|
||||
return entries.appendOwned(arena, name, 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(@alignCast(@ptrCast(option)), "disabled") != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try parser.optionGetSelected(option)) {
|
||||
const value = try parser.optionGetValue(option);
|
||||
try entries.appendOwned(arena, name, 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.inputGetType(@ptrCast(element));
|
||||
// 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-~-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>
|
||||
\\ <input type=text name=abc value=123 form=form1>
|
||||
});
|
||||
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 input = document.createElement('input');", null },
|
||||
.{ "input.name = 'dyn'; input.value= 'dyn-v'; form1.appendChild(input);", 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-~-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
|
||||
\\dyn=dyn-v
|
||||
},
|
||||
}, .{});
|
||||
}
|
||||
|
||||
test "Browser.FormData: urlEncode" {
|
||||
var arr: std.ArrayListUnmanaged(u8) = .empty;
|
||||
defer arr.deinit(testing.allocator);
|
||||
|
||||
{
|
||||
var fd = FormData{ .entries = .{} };
|
||||
try testing.expectError(error.EncodingNotSupported, fd.write("unknown", arr.writer(testing.allocator)));
|
||||
|
||||
try fd.write(null, arr.writer(testing.allocator));
|
||||
try testing.expectEqual("", arr.items);
|
||||
|
||||
try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
|
||||
try testing.expectEqual("", arr.items);
|
||||
}
|
||||
|
||||
{
|
||||
var fd = FormData{ .entries = kv.List.fromOwnedSlice(@constCast(&[_]kv.KeyValue{
|
||||
.{ .key = "a", .value = "1" },
|
||||
.{ .key = "it's over", .value = "9000 !!!" },
|
||||
.{ .key = "em~ot", .value = "ok: ☺" },
|
||||
})) };
|
||||
const expected = "a=1&it%27s+over=9000+%21%21%21&em%7Eot=ok%3A+%E2%98%BA";
|
||||
try fd.write(null, arr.writer(testing.allocator));
|
||||
try testing.expectEqual(expected, arr.items);
|
||||
|
||||
arr.clearRetainingCapacity();
|
||||
try fd.write("application/x-www-form-urlencoded", arr.writer(testing.allocator));
|
||||
try testing.expectEqual(expected, arr.items);
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
const dump = @import("../dump.zig");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
pub const Interfaces = .{
|
||||
XMLSerializer,
|
||||
};
|
||||
|
||||
// https://w3c.github.io/DOM-Parsing/#dom-xmlserializer-constructor
|
||||
pub const XMLSerializer = struct {
|
||||
pub fn constructor() !XMLSerializer {
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _serializeToString(_: *const XMLSerializer, root: *parser.Node, page: *Page) ![]const u8 {
|
||||
var buf = std.ArrayList(u8).init(page.arena);
|
||||
switch (try parser.nodeType(root)) {
|
||||
.document => try dump.writeHTML(@as(*parser.Document, @ptrCast(root)), buf.writer()),
|
||||
.document_type => try dump.writeDocType(@as(*parser.DocumentType, @ptrCast(root)), buf.writer()),
|
||||
else => try dump.writeNode(root, buf.writer()),
|
||||
}
|
||||
return buf.items;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.XMLSerializer" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "const s = new XMLSerializer()", "undefined" },
|
||||
.{ "s.serializeToString(document.getElementById('para'))", "<p id=\"para\"> And</p>" },
|
||||
}, .{});
|
||||
}
|
||||
test "Browser.XMLSerializer with DOCTYPE" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{ .html = "<!DOCTYPE html><html><head></head><body></body></html>" });
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "new XMLSerializer().serializeToString(document.doctype)", "<!DOCTYPE html>" },
|
||||
}, .{});
|
||||
}
|
||||
498
src/cdp/Node.zig
498
src/cdp/Node.zig
@@ -1,498 +0,0 @@
|
||||
// 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 log = @import("../log.zig");
|
||||
const parser = @import("../browser/netsurf.zig");
|
||||
|
||||
pub const Id = u32;
|
||||
|
||||
const Node = @This();
|
||||
|
||||
id: Id,
|
||||
_node: *parser.Node,
|
||||
set_child_nodes_event: bool,
|
||||
|
||||
// Whenever we send a node to the client, we register it here for future lookup.
|
||||
// We maintain a node -> id and id -> node lookup.
|
||||
pub const Registry = struct {
|
||||
node_id: u32,
|
||||
allocator: Allocator,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
node_pool: std.heap.MemoryPool(Node),
|
||||
lookup_by_id: std.AutoHashMapUnmanaged(Id, *Node),
|
||||
lookup_by_node: std.HashMapUnmanaged(*parser.Node, *Node, NodeContext, std.hash_map.default_max_load_percentage),
|
||||
|
||||
pub fn init(allocator: Allocator) Registry {
|
||||
return .{
|
||||
.node_id = 0,
|
||||
.lookup_by_id = .{},
|
||||
.lookup_by_node = .{},
|
||||
.allocator = allocator,
|
||||
.arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.node_pool = std.heap.MemoryPool(Node).init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Registry) void {
|
||||
const allocator = self.allocator;
|
||||
self.lookup_by_id.deinit(allocator);
|
||||
self.lookup_by_node.deinit(allocator);
|
||||
self.node_pool.deinit();
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
pub fn reset(self: *Registry) void {
|
||||
self.lookup_by_id.clearRetainingCapacity();
|
||||
self.lookup_by_node.clearRetainingCapacity();
|
||||
_ = self.arena.reset(.{ .retain_with_limit = 1024 });
|
||||
_ = self.node_pool.reset(.{ .retain_with_limit = 1024 });
|
||||
}
|
||||
|
||||
pub fn register(self: *Registry, n: *parser.Node) !*Node {
|
||||
const node_lookup_gop = try self.lookup_by_node.getOrPut(self.allocator, n);
|
||||
if (node_lookup_gop.found_existing) {
|
||||
return node_lookup_gop.value_ptr.*;
|
||||
}
|
||||
|
||||
// on error, we're probably going to abort the entire browser context
|
||||
// but, just in case, let's try to keep things tidy.
|
||||
errdefer _ = self.lookup_by_node.remove(n);
|
||||
|
||||
const node = try self.node_pool.create();
|
||||
errdefer self.node_pool.destroy(node);
|
||||
|
||||
const id = self.node_id;
|
||||
self.node_id = id + 1;
|
||||
|
||||
node.* = .{
|
||||
._node = n,
|
||||
.id = id,
|
||||
.set_child_nodes_event = false,
|
||||
};
|
||||
|
||||
node_lookup_gop.value_ptr.* = node;
|
||||
try self.lookup_by_id.putNoClobber(self.allocator, id, node);
|
||||
return node;
|
||||
}
|
||||
};
|
||||
|
||||
const NodeContext = struct {
|
||||
pub fn hash(_: NodeContext, n: *parser.Node) u64 {
|
||||
return std.hash.Wyhash.hash(0, std.mem.asBytes(&@intFromPtr(n)));
|
||||
}
|
||||
|
||||
pub fn eql(_: NodeContext, a: *parser.Node, b: *parser.Node) bool {
|
||||
return @intFromPtr(a) == @intFromPtr(b);
|
||||
}
|
||||
};
|
||||
|
||||
// Searches are a 3 step process:
|
||||
// 1 - Dom.performSearch
|
||||
// 2 - Dom.getSearchResults
|
||||
// 3 - Dom.discardSearchResults
|
||||
//
|
||||
// For a given browser context, we can have multiple active searches. I.e.
|
||||
// performSearch could be called multiple times without getSearchResults or
|
||||
// discardSearchResults being called. We keep these active searches in the
|
||||
// browser context's node_search_list, which is a SearchList. Since we don't
|
||||
// expect many active searches (mostly just 1), a list is fine to scan through.
|
||||
pub const Search = struct {
|
||||
name: []const u8,
|
||||
node_ids: []const Id,
|
||||
|
||||
pub const List = struct {
|
||||
registry: *Registry,
|
||||
search_id: u16 = 0,
|
||||
arena: std.heap.ArenaAllocator,
|
||||
searches: std.ArrayListUnmanaged(Search) = .{},
|
||||
|
||||
pub fn init(allocator: Allocator, registry: *Registry) List {
|
||||
return .{
|
||||
.registry = registry,
|
||||
.arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *List) void {
|
||||
self.arena.deinit();
|
||||
}
|
||||
|
||||
pub fn reset(self: *List) void {
|
||||
self.search_id = 0;
|
||||
self.searches = .{};
|
||||
_ = self.arena.reset(.{ .retain_with_limit = 4096 });
|
||||
}
|
||||
|
||||
pub fn create(self: *List, nodes: []const *parser.Node) !Search {
|
||||
const id = self.search_id;
|
||||
defer self.search_id = id +% 1;
|
||||
|
||||
const arena = self.arena.allocator();
|
||||
|
||||
const name = switch (id) {
|
||||
0 => "0",
|
||||
1 => "1",
|
||||
2 => "2",
|
||||
3 => "3",
|
||||
4 => "4",
|
||||
5 => "5",
|
||||
6 => "6",
|
||||
7 => "7",
|
||||
8 => "8",
|
||||
9 => "9",
|
||||
else => try std.fmt.allocPrint(arena, "{d}", .{id}),
|
||||
};
|
||||
|
||||
var registry = self.registry;
|
||||
const node_ids = try arena.alloc(Id, nodes.len);
|
||||
for (nodes, node_ids) |node, *node_id| {
|
||||
node_id.* = (try registry.register(node)).id;
|
||||
}
|
||||
|
||||
const search = Search{
|
||||
.name = name,
|
||||
.node_ids = node_ids,
|
||||
};
|
||||
try self.searches.append(arena, search);
|
||||
return search;
|
||||
}
|
||||
|
||||
pub fn remove(self: *List, name: []const u8) void {
|
||||
for (self.searches.items, 0..) |search, i| {
|
||||
if (std.mem.eql(u8, name, search.name)) {
|
||||
_ = self.searches.swapRemove(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(self: *const List, name: []const u8) ?Search {
|
||||
for (self.searches.items) |search| {
|
||||
if (std.mem.eql(u8, name, search.name)) {
|
||||
return search;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Need a custom writer, because we can't just serialize the node as-is.
|
||||
// Sometimes we want to serializ the node without chidren, sometimes with just
|
||||
// its direct children, and sometimes the entire tree.
|
||||
// (For now, we only support direct children)
|
||||
|
||||
pub const Writer = struct {
|
||||
opts: Opts,
|
||||
node: *const Node,
|
||||
registry: *Registry,
|
||||
|
||||
pub const Opts = struct {};
|
||||
|
||||
pub fn jsonStringify(self: *const Writer, w: anytype) !void {
|
||||
self.toJSON(w) catch |err| {
|
||||
// The only error our jsonStringify method can return is
|
||||
// @TypeOf(w).Error. In other words, our code can't return its own
|
||||
// error, we can only return a writer error. Kinda sucks.
|
||||
log.err(.cdp, "json stringify", .{ .err = err });
|
||||
return error.OutOfMemory;
|
||||
};
|
||||
}
|
||||
|
||||
fn toJSON(self: *const Writer, w: anytype) !void {
|
||||
try w.beginObject();
|
||||
try self.writeCommon(self.node, false, w);
|
||||
|
||||
{
|
||||
var registry = self.registry;
|
||||
const child_nodes = try parser.nodeGetChildNodes(self.node._node);
|
||||
const child_count = try parser.nodeListLength(child_nodes);
|
||||
|
||||
var i: usize = 0;
|
||||
try w.objectField("children");
|
||||
try w.beginArray();
|
||||
for (0..child_count) |_| {
|
||||
const child = (try parser.nodeListItem(child_nodes, @intCast(i))) orelse break;
|
||||
const child_node = try registry.register(child);
|
||||
try w.beginObject();
|
||||
try self.writeCommon(child_node, true, w);
|
||||
try w.endObject();
|
||||
i += 1;
|
||||
}
|
||||
try w.endArray();
|
||||
|
||||
try w.objectField("childNodeCount");
|
||||
try w.write(i);
|
||||
}
|
||||
|
||||
try w.endObject();
|
||||
}
|
||||
|
||||
fn writeCommon(self: *const Writer, node: *const Node, include_child_count: bool, w: anytype) !void {
|
||||
try w.objectField("nodeId");
|
||||
try w.write(node.id);
|
||||
|
||||
try w.objectField("backendNodeId");
|
||||
try w.write(node.id);
|
||||
|
||||
const n = node._node;
|
||||
|
||||
if (try parser.nodeParentNode(n)) |p| {
|
||||
const parent_node = try self.registry.register(p);
|
||||
try w.objectField("parentId");
|
||||
try w.write(parent_node.id);
|
||||
}
|
||||
|
||||
const _map = try parser.nodeGetAttributes(n);
|
||||
if (_map) |map| {
|
||||
const attr_count = try parser.namedNodeMapGetLength(map);
|
||||
try w.objectField("attributes");
|
||||
try w.beginArray();
|
||||
for (0..attr_count) |i| {
|
||||
const attr = try parser.namedNodeMapItem(map, @intCast(i)) orelse continue;
|
||||
try w.write(try parser.attributeGetName(attr));
|
||||
try w.write(try parser.attributeGetValue(attr) orelse continue);
|
||||
}
|
||||
try w.endArray();
|
||||
}
|
||||
|
||||
try w.objectField("nodeType");
|
||||
try w.write(@intFromEnum(try parser.nodeType(n)));
|
||||
|
||||
try w.objectField("nodeName");
|
||||
try w.write(try parser.nodeName(n));
|
||||
|
||||
try w.objectField("localName");
|
||||
try w.write(try parser.nodeLocalName(n));
|
||||
|
||||
try w.objectField("nodeValue");
|
||||
try w.write((try parser.nodeValue(n)) orelse "");
|
||||
|
||||
if (include_child_count) {
|
||||
try w.objectField("childNodeCount");
|
||||
const child_nodes = try parser.nodeGetChildNodes(n);
|
||||
try w.write(try parser.nodeListLength(child_nodes));
|
||||
}
|
||||
|
||||
try w.objectField("documentURL");
|
||||
try w.write(null);
|
||||
|
||||
try w.objectField("baseURL");
|
||||
try w.write(null);
|
||||
|
||||
try w.objectField("xmlVersion");
|
||||
try w.write("");
|
||||
|
||||
try w.objectField("compatibilityMode");
|
||||
try w.write("NoQuirksMode");
|
||||
|
||||
try w.objectField("isScrollable");
|
||||
try w.write(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
test "cdp Node: Registry register" {
|
||||
var registry = Registry.init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
try testing.expectEqual(0, registry.lookup_by_id.count());
|
||||
try testing.expectEqual(0, registry.lookup_by_node.count());
|
||||
|
||||
var doc = try testing.Document.init("<a id=a1>link1</a><div id=d2><p>other</p></div>");
|
||||
defer doc.deinit();
|
||||
|
||||
{
|
||||
const n = (try doc.querySelector("#a1")).?;
|
||||
const node = try registry.register(n);
|
||||
const n1b = registry.lookup_by_id.get(0).?;
|
||||
const n1c = registry.lookup_by_node.get(node._node).?;
|
||||
try testing.expectEqual(node, n1b);
|
||||
try testing.expectEqual(node, n1c);
|
||||
|
||||
try testing.expectEqual(0, node.id);
|
||||
try testing.expectEqual(n, node._node);
|
||||
}
|
||||
|
||||
{
|
||||
const n = (try doc.querySelector("p")).?;
|
||||
const node = try registry.register(n);
|
||||
const n1b = registry.lookup_by_id.get(1).?;
|
||||
const n1c = registry.lookup_by_node.get(node._node).?;
|
||||
try testing.expectEqual(node, n1b);
|
||||
try testing.expectEqual(node, n1c);
|
||||
|
||||
try testing.expectEqual(1, node.id);
|
||||
try testing.expectEqual(n, node._node);
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp Node: search list" {
|
||||
var registry = Registry.init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var search_list = Search.List.init(testing.allocator, ®istry);
|
||||
defer search_list.deinit();
|
||||
|
||||
{
|
||||
// empty search list, noops
|
||||
search_list.remove("0");
|
||||
try testing.expectEqual(null, search_list.get("0"));
|
||||
}
|
||||
|
||||
{
|
||||
// empty nodes
|
||||
const s1 = try search_list.create(&.{});
|
||||
try testing.expectEqual("0", s1.name);
|
||||
try testing.expectEqual(0, s1.node_ids.len);
|
||||
|
||||
const s2 = search_list.get("0").?;
|
||||
try testing.expectEqual("0", s2.name);
|
||||
try testing.expectEqual(0, s2.node_ids.len);
|
||||
|
||||
search_list.remove("0");
|
||||
try testing.expectEqual(null, search_list.get("0"));
|
||||
}
|
||||
|
||||
{
|
||||
var doc = try testing.Document.init("<a id=a1></a><a id=a2></a>");
|
||||
defer doc.deinit();
|
||||
|
||||
const s1 = try search_list.create(try doc.querySelectorAll("a"));
|
||||
try testing.expectEqual("1", s1.name);
|
||||
try testing.expectEqualSlices(u32, &.{ 0, 1 }, s1.node_ids);
|
||||
|
||||
try testing.expectEqual(2, registry.lookup_by_id.count());
|
||||
try testing.expectEqual(2, registry.lookup_by_node.count());
|
||||
|
||||
const s2 = try search_list.create(try doc.querySelectorAll("#a1"));
|
||||
try testing.expectEqual("2", s2.name);
|
||||
try testing.expectEqualSlices(u32, &.{0}, s2.node_ids);
|
||||
|
||||
const s3 = try search_list.create(try doc.querySelectorAll("#a2"));
|
||||
try testing.expectEqual("3", s3.name);
|
||||
try testing.expectEqualSlices(u32, &.{1}, s3.node_ids);
|
||||
|
||||
try testing.expectEqual(2, registry.lookup_by_id.count());
|
||||
try testing.expectEqual(2, registry.lookup_by_node.count());
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp Node: Writer" {
|
||||
var registry = Registry.init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var doc = try testing.Document.init("<a id=a1></a><a id=a2></a>");
|
||||
defer doc.deinit();
|
||||
|
||||
{
|
||||
const node = try registry.register(doc.asNode());
|
||||
const json = try std.json.stringifyAlloc(testing.allocator, Writer{
|
||||
.node = node,
|
||||
.opts = .{},
|
||||
.registry = ®istry,
|
||||
}, .{});
|
||||
defer testing.allocator.free(json);
|
||||
|
||||
try testing.expectJson(.{
|
||||
.nodeId = 0,
|
||||
.backendNodeId = 0,
|
||||
.nodeType = 9,
|
||||
.nodeName = "#document",
|
||||
.localName = "",
|
||||
.nodeValue = "",
|
||||
.documentURL = null,
|
||||
.baseURL = null,
|
||||
.xmlVersion = "",
|
||||
.isScrollable = false,
|
||||
.compatibilityMode = "NoQuirksMode",
|
||||
.childNodeCount = 1,
|
||||
.children = &.{.{
|
||||
.nodeId = 1,
|
||||
.backendNodeId = 1,
|
||||
.nodeType = 1,
|
||||
.nodeName = "HTML",
|
||||
.localName = "html",
|
||||
.nodeValue = "",
|
||||
.childNodeCount = 2,
|
||||
.documentURL = null,
|
||||
.baseURL = null,
|
||||
.xmlVersion = "",
|
||||
.compatibilityMode = "NoQuirksMode",
|
||||
.isScrollable = false,
|
||||
}},
|
||||
}, json);
|
||||
}
|
||||
|
||||
{
|
||||
const node = registry.lookup_by_id.get(1).?;
|
||||
const json = try std.json.stringifyAlloc(testing.allocator, Writer{
|
||||
.node = node,
|
||||
.opts = .{},
|
||||
.registry = ®istry,
|
||||
}, .{});
|
||||
defer testing.allocator.free(json);
|
||||
|
||||
try testing.expectJson(.{
|
||||
.nodeId = 1,
|
||||
.backendNodeId = 1,
|
||||
.nodeType = 1,
|
||||
.nodeName = "HTML",
|
||||
.localName = "html",
|
||||
.nodeValue = "",
|
||||
.childNodeCount = 2,
|
||||
.documentURL = null,
|
||||
.baseURL = null,
|
||||
.xmlVersion = "",
|
||||
.compatibilityMode = "NoQuirksMode",
|
||||
.isScrollable = false,
|
||||
.children = &.{ .{
|
||||
.nodeId = 2,
|
||||
.backendNodeId = 2,
|
||||
.nodeType = 1,
|
||||
.nodeName = "HEAD",
|
||||
.localName = "head",
|
||||
.nodeValue = "",
|
||||
.childNodeCount = 0,
|
||||
.documentURL = null,
|
||||
.baseURL = null,
|
||||
.xmlVersion = "",
|
||||
.compatibilityMode = "NoQuirksMode",
|
||||
.isScrollable = false,
|
||||
.parentId = 1,
|
||||
}, .{
|
||||
.nodeId = 3,
|
||||
.backendNodeId = 3,
|
||||
.nodeType = 1,
|
||||
.nodeName = "BODY",
|
||||
.localName = "body",
|
||||
.nodeValue = "",
|
||||
.childNodeCount = 2,
|
||||
.documentURL = null,
|
||||
.baseURL = null,
|
||||
.xmlVersion = "",
|
||||
.compatibilityMode = "NoQuirksMode",
|
||||
.isScrollable = false,
|
||||
.parentId = 1,
|
||||
} },
|
||||
}, json);
|
||||
}
|
||||
}
|
||||
148
src/cdp/browser.zig
Normal file
148
src/cdp/browser.zig
Normal file
@@ -0,0 +1,148 @@
|
||||
// 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 server = @import("../server.zig");
|
||||
const Ctx = server.Ctx;
|
||||
const cdp = @import("cdp.zig");
|
||||
const result = cdp.result;
|
||||
const IncomingMessage = @import("msg.zig").IncomingMessage;
|
||||
const Input = @import("msg.zig").Input;
|
||||
|
||||
const log = std.log.scoped(.cdp);
|
||||
|
||||
const Methods = enum {
|
||||
getVersion,
|
||||
setDownloadBehavior,
|
||||
getWindowForTarget,
|
||||
setWindowBounds,
|
||||
};
|
||||
|
||||
pub fn browser(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
action: []const u8,
|
||||
ctx: *Ctx,
|
||||
) ![]const u8 {
|
||||
const method = std.meta.stringToEnum(Methods, action) orelse
|
||||
return error.UnknownMethod;
|
||||
return switch (method) {
|
||||
.getVersion => getVersion(alloc, msg, ctx),
|
||||
.setDownloadBehavior => setDownloadBehavior(alloc, msg, ctx),
|
||||
.getWindowForTarget => getWindowForTarget(alloc, msg, ctx),
|
||||
.setWindowBounds => setWindowBounds(alloc, msg, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: hard coded data
|
||||
const ProtocolVersion = "1.3";
|
||||
const Product = "Chrome/124.0.6367.29";
|
||||
const Revision = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4";
|
||||
const UserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
const JsVersion = "12.4.254.8";
|
||||
|
||||
fn getVersion(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
// input
|
||||
const input = try Input(void).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
log.debug("Req > id {d}, method {s}", .{ input.id, "browser.getVersion" });
|
||||
|
||||
// ouput
|
||||
const Res = struct {
|
||||
protocolVersion: []const u8 = ProtocolVersion,
|
||||
product: []const u8 = Product,
|
||||
revision: []const u8 = Revision,
|
||||
userAgent: []const u8 = UserAgent,
|
||||
jsVersion: []const u8 = JsVersion,
|
||||
};
|
||||
return result(alloc, input.id, Res, .{}, null);
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setDownloadBehavior(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
// input
|
||||
const Params = struct {
|
||||
behavior: []const u8,
|
||||
browserContextId: ?[]const u8 = null,
|
||||
downloadPath: ?[]const u8 = null,
|
||||
eventsEnabled: ?bool = null,
|
||||
};
|
||||
const input = try Input(Params).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
log.debug("REQ > id {d}, method {s}", .{ input.id, "browser.setDownloadBehavior" });
|
||||
|
||||
// output
|
||||
return result(alloc, input.id, null, null, null);
|
||||
}
|
||||
|
||||
// TODO: hard coded ID
|
||||
const DevToolsWindowID = 1923710101;
|
||||
|
||||
fn getWindowForTarget(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
|
||||
// input
|
||||
const Params = struct {
|
||||
targetId: ?[]const u8 = null,
|
||||
};
|
||||
const input = try Input(?Params).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
std.debug.assert(input.sessionId != null);
|
||||
log.debug("Req > id {d}, method {s}", .{ input.id, "browser.getWindowForTarget" });
|
||||
|
||||
// output
|
||||
const Resp = struct {
|
||||
windowId: u64 = DevToolsWindowID,
|
||||
bounds: struct {
|
||||
left: ?u64 = null,
|
||||
top: ?u64 = null,
|
||||
width: ?u64 = null,
|
||||
height: ?u64 = null,
|
||||
windowState: []const u8 = "normal",
|
||||
} = .{},
|
||||
};
|
||||
return result(alloc, input.id, Resp, Resp{}, input.sessionId);
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setWindowBounds(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
|
||||
// input
|
||||
const input = try Input(void).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
log.debug("Req > id {d}, method {s}", .{ input.id, "browser.setWindowBounds" });
|
||||
|
||||
// output
|
||||
return result(alloc, input.id, null, null, input.sessionId);
|
||||
}
|
||||
936
src/cdp/cdp.zig
936
src/cdp/cdp.zig
@@ -17,777 +17,217 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const json = std.json;
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../app.zig").App;
|
||||
const Env = @import("../browser/env.zig").Env;
|
||||
const Browser = @import("../browser/browser.zig").Browser;
|
||||
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;
|
||||
const server = @import("../server.zig");
|
||||
const Ctx = server.Ctx;
|
||||
|
||||
const polyfill = @import("../browser/polyfill/polyfill.zig");
|
||||
const browser = @import("browser.zig").browser;
|
||||
const target = @import("target.zig").target;
|
||||
const page = @import("page.zig").page;
|
||||
const log = @import("log.zig").log;
|
||||
const runtime = @import("runtime.zig").runtime;
|
||||
const network = @import("network.zig").network;
|
||||
const emulation = @import("emulation.zig").emulation;
|
||||
const fetch = @import("fetch.zig").fetch;
|
||||
const performance = @import("performance.zig").performance;
|
||||
const IncomingMessage = @import("msg.zig").IncomingMessage;
|
||||
const Input = @import("msg.zig").Input;
|
||||
const inspector = @import("inspector.zig").inspector;
|
||||
const dom = @import("dom.zig").dom;
|
||||
const css = @import("css.zig").css;
|
||||
const security = @import("security.zig").security;
|
||||
|
||||
pub const URL_BASE = "chrome://newtab/";
|
||||
pub const LOADER_ID = "LOADERID24DD2FD56CF1EF33C965C79C";
|
||||
const log_cdp = std.log.scoped(.cdp);
|
||||
|
||||
pub const CDP = CDPT(struct {
|
||||
const Client = *@import("../server.zig").Client;
|
||||
});
|
||||
pub const Error = error{
|
||||
UnknonwDomain,
|
||||
UnknownMethod,
|
||||
NoResponse,
|
||||
RequestWithoutID,
|
||||
};
|
||||
|
||||
const SessionIdGen = Incrementing(u32, "SID");
|
||||
const TargetIdGen = Incrementing(u32, "TID");
|
||||
const LoaderIdGen = Incrementing(u32, "LID");
|
||||
const BrowserContextIdGen = Incrementing(u32, "BID");
|
||||
|
||||
// Generic so that we can inject mocks into it.
|
||||
pub fn CDPT(comptime TypeProvider: type) type {
|
||||
return struct {
|
||||
// Used for sending message to the client and closing on error
|
||||
client: TypeProvider.Client,
|
||||
|
||||
allocator: Allocator,
|
||||
|
||||
// The active browser
|
||||
browser: Browser,
|
||||
|
||||
// when true, any target creation must be attached.
|
||||
target_auto_attach: bool = false,
|
||||
|
||||
target_id_gen: TargetIdGen = .{},
|
||||
loader_id_gen: LoaderIdGen = .{},
|
||||
session_id_gen: SessionIdGen = .{},
|
||||
browser_context_id_gen: BrowserContextIdGen = .{},
|
||||
|
||||
browser_context: ?BrowserContext(Self),
|
||||
|
||||
// Re-used arena for processing a message. We're assuming that we're getting
|
||||
// 1 message at a time.
|
||||
message_arena: std.heap.ArenaAllocator,
|
||||
|
||||
// Used for processing notifications within a browser context.
|
||||
notification_arena: std.heap.ArenaAllocator,
|
||||
|
||||
// Extra headers to add to all requests. TBD under which conditions this should be reset.
|
||||
extra_headers: std.ArrayListUnmanaged(std.http.Header) = .empty,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn init(app: *App, client: TypeProvider.Client) !Self {
|
||||
const allocator = app.allocator;
|
||||
const browser = try Browser.init(app);
|
||||
errdefer browser.deinit();
|
||||
|
||||
return .{
|
||||
.client = client,
|
||||
.browser = browser,
|
||||
.allocator = allocator,
|
||||
.browser_context = null,
|
||||
.message_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
.notification_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
pub fn isCdpError(err: anyerror) ?Error {
|
||||
// see https://github.com/ziglang/zig/issues/2473
|
||||
const errors = @typeInfo(Error).ErrorSet.?;
|
||||
inline for (errors) |e| {
|
||||
if (std.mem.eql(u8, e.name, @errorName(err))) {
|
||||
return @errorCast(err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
if (self.browser_context) |*bc| {
|
||||
bc.deinit();
|
||||
}
|
||||
self.browser.deinit();
|
||||
self.message_arena.deinit();
|
||||
self.notification_arena.deinit();
|
||||
}
|
||||
const Domains = enum {
|
||||
Browser,
|
||||
Target,
|
||||
Page,
|
||||
Log,
|
||||
Runtime,
|
||||
Network,
|
||||
DOM,
|
||||
CSS,
|
||||
Inspector,
|
||||
Emulation,
|
||||
Fetch,
|
||||
Performance,
|
||||
Security,
|
||||
};
|
||||
|
||||
pub fn handleMessage(self: *Self, msg: []const u8) bool {
|
||||
// if there's an error, it's already been logged
|
||||
self.processMessage(msg) catch return false;
|
||||
return true;
|
||||
}
|
||||
// The caller is responsible for calling `free` on the returned slice.
|
||||
pub fn do(
|
||||
alloc: std.mem.Allocator,
|
||||
s: []const u8,
|
||||
ctx: *Ctx,
|
||||
) anyerror![]const u8 {
|
||||
|
||||
pub fn processMessage(self: *Self, msg: []const u8) !void {
|
||||
const arena = &self.message_arena;
|
||||
defer _ = arena.reset(.{ .retain_with_limit = 1024 * 16 });
|
||||
return self.dispatch(arena.allocator(), self, msg);
|
||||
}
|
||||
// incoming message parser
|
||||
var msg = IncomingMessage.init(alloc, s);
|
||||
defer msg.deinit();
|
||||
|
||||
// Called from above, in processMessage which handles client messages
|
||||
// but can also be called internally. For example, Target.sendMessageToTarget
|
||||
// calls back into dispatch to capture the response.
|
||||
pub fn dispatch(self: *Self, arena: Allocator, sender: anytype, str: []const u8) !void {
|
||||
const input = json.parseFromSliceLeaky(InputMessage, arena, str, .{
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch return error.InvalidJSON;
|
||||
return dispatch(alloc, &msg, ctx);
|
||||
}
|
||||
|
||||
var command = Command(Self, @TypeOf(sender)){
|
||||
.input = .{
|
||||
.json = str,
|
||||
.id = input.id,
|
||||
.action = "",
|
||||
.params = input.params,
|
||||
.session_id = input.sessionId,
|
||||
},
|
||||
.cdp = self,
|
||||
.arena = arena,
|
||||
.sender = sender,
|
||||
.browser_context = if (self.browser_context) |*bc| bc else null,
|
||||
};
|
||||
pub fn dispatch(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
ctx: *Ctx,
|
||||
) anyerror![]const u8 {
|
||||
const method = try msg.getMethod();
|
||||
|
||||
// See dispatchStartupCommand for more info on this.
|
||||
var is_startup = false;
|
||||
if (input.sessionId) |input_session_id| {
|
||||
if (std.mem.eql(u8, input_session_id, "STARTUP")) {
|
||||
is_startup = true;
|
||||
} else if (self.isValidSessionId(input_session_id) == false) {
|
||||
return command.sendError(-32001, "Unknown sessionId");
|
||||
}
|
||||
}
|
||||
// retrieve domain from method
|
||||
var iter = std.mem.splitScalar(u8, method, '.');
|
||||
const domain = std.meta.stringToEnum(Domains, iter.first()) orelse
|
||||
return error.UnknonwDomain;
|
||||
|
||||
if (is_startup) {
|
||||
dispatchStartupCommand(&command) catch |err| {
|
||||
command.sendError(-31999, @errorName(err)) catch {};
|
||||
return err;
|
||||
};
|
||||
} else {
|
||||
dispatchCommand(&command, input.method) catch |err| {
|
||||
command.sendError(-31998, @errorName(err)) catch {};
|
||||
return err;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A CDP session isn't 100% fully driven by the driver. There's are
|
||||
// independent actions that the browser is expected to take. For example
|
||||
// Puppeteer expects the browser to startup a tab and thus have existing
|
||||
// targets.
|
||||
// To this end, we create a [very] dummy BrowserContext, Target and
|
||||
// Session. There isn't actually a BrowserContext, just a special id.
|
||||
// When messages are received with the "STARTUP" sessionId, we do
|
||||
// "special" handling - the bare minimum we need to do until the driver
|
||||
// switches to a real BrowserContext.
|
||||
// (I can imagine this logic will become driver-specific)
|
||||
fn dispatchStartupCommand(command: anytype) !void {
|
||||
return command.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn dispatchCommand(command: anytype, method: []const u8) !void {
|
||||
const domain = blk: {
|
||||
const i = std.mem.indexOfScalarPos(u8, method, 0, '.') orelse {
|
||||
return error.InvalidMethod;
|
||||
};
|
||||
command.input.action = method[i + 1 ..];
|
||||
break :blk method[0..i];
|
||||
};
|
||||
|
||||
switch (domain.len) {
|
||||
3 => switch (@as(u24, @bitCast(domain[0..3].*))) {
|
||||
asUint(u24, "DOM") => return @import("domains/dom.zig").processMessage(command),
|
||||
asUint(u24, "Log") => return @import("domains/log.zig").processMessage(command),
|
||||
asUint(u24, "CSS") => return @import("domains/css.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
4 => switch (@as(u32, @bitCast(domain[0..4].*))) {
|
||||
asUint(u32, "Page") => return @import("domains/page.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
5 => switch (@as(u40, @bitCast(domain[0..5].*))) {
|
||||
asUint(u40, "Fetch") => return @import("domains/fetch.zig").processMessage(command),
|
||||
asUint(u40, "Input") => return @import("domains/input.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
6 => switch (@as(u48, @bitCast(domain[0..6].*))) {
|
||||
asUint(u48, "Target") => return @import("domains/target.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
7 => switch (@as(u56, @bitCast(domain[0..7].*))) {
|
||||
asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command),
|
||||
asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command),
|
||||
asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command),
|
||||
asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
8 => switch (@as(u64, @bitCast(domain[0..8].*))) {
|
||||
asUint(u64, "Security") => return @import("domains/security.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
9 => switch (@as(u72, @bitCast(domain[0..9].*))) {
|
||||
asUint(u72, "Emulation") => return @import("domains/emulation.zig").processMessage(command),
|
||||
asUint(u72, "Inspector") => return @import("domains/inspector.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
11 => switch (@as(u88, @bitCast(domain[0..11].*))) {
|
||||
asUint(u88, "Performance") => return @import("domains/performance.zig").processMessage(command),
|
||||
else => {},
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
return error.UnknownDomain;
|
||||
}
|
||||
|
||||
fn isValidSessionId(self: *const Self, input_session_id: []const u8) bool {
|
||||
const browser_context = &(self.browser_context orelse return false);
|
||||
const session_id = browser_context.session_id orelse return false;
|
||||
return std.mem.eql(u8, session_id, input_session_id);
|
||||
}
|
||||
|
||||
pub fn createBrowserContext(self: *Self) ![]const u8 {
|
||||
if (self.browser_context != null) {
|
||||
return error.AlreadyExists;
|
||||
}
|
||||
const id = self.browser_context_id_gen.next();
|
||||
|
||||
self.browser_context = @as(BrowserContext(Self), undefined);
|
||||
const browser_context = &self.browser_context.?;
|
||||
|
||||
try BrowserContext(Self).init(browser_context, id, self);
|
||||
return id;
|
||||
}
|
||||
|
||||
pub fn disposeBrowserContext(self: *Self, browser_context_id: []const u8) bool {
|
||||
const bc = &(self.browser_context orelse return false);
|
||||
if (std.mem.eql(u8, bc.id, browser_context_id) == false) {
|
||||
return false;
|
||||
}
|
||||
bc.deinit();
|
||||
self.browser.closeSession();
|
||||
self.browser_context = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const SendEventOpts = struct {
|
||||
session_id: ?[]const u8 = null,
|
||||
};
|
||||
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: SendEventOpts) !void {
|
||||
return self.sendJSON(.{
|
||||
.method = method,
|
||||
.params = if (comptime @typeInfo(@TypeOf(p)) == .null) struct {}{} else p,
|
||||
.sessionId = opts.session_id,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sendJSON(self: *Self, message: anytype) !void {
|
||||
return self.client.sendJSON(message, .{
|
||||
.emit_null_optional_fields = false,
|
||||
});
|
||||
}
|
||||
// select corresponding domain
|
||||
const action = iter.next() orelse return error.BadMethod;
|
||||
return switch (domain) {
|
||||
.Browser => browser(alloc, msg, action, ctx),
|
||||
.Target => target(alloc, msg, action, ctx),
|
||||
.Page => page(alloc, msg, action, ctx),
|
||||
.Log => log(alloc, msg, action, ctx),
|
||||
.Runtime => runtime(alloc, msg, action, ctx),
|
||||
.Network => network(alloc, msg, action, ctx),
|
||||
.DOM => dom(alloc, msg, action, ctx),
|
||||
.CSS => css(alloc, msg, action, ctx),
|
||||
.Inspector => inspector(alloc, msg, action, ctx),
|
||||
.Emulation => emulation(alloc, msg, action, ctx),
|
||||
.Fetch => fetch(alloc, msg, action, ctx),
|
||||
.Performance => performance(alloc, msg, action, ctx),
|
||||
.Security => security(alloc, msg, action, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn BrowserContext(comptime CDP_T: type) type {
|
||||
const Node = @import("Node.zig");
|
||||
pub const State = struct {
|
||||
executionContextId: u32 = 0,
|
||||
contextID: ?[]const u8 = null,
|
||||
sessionID: ?[]const u8 = null,
|
||||
frameID: []const u8 = FrameID,
|
||||
url: []const u8 = URLBase,
|
||||
securityOrigin: []const u8 = URLBase,
|
||||
secureContextType: []const u8 = "Secure", // TODO: enum
|
||||
loaderID: []const u8 = LoaderID,
|
||||
|
||||
return struct {
|
||||
id: []const u8,
|
||||
cdp: *CDP_T,
|
||||
page_life_cycle_events: bool = false, // TODO; Target based value
|
||||
};
|
||||
|
||||
// Represents the browser session. There is no equivalent in CDP. For
|
||||
// all intents and purpose, from CDP's point of view our Browser and
|
||||
// our Session more or less maps to a BrowserContext. THIS HAS ZERO
|
||||
// RELATION TO SESSION_ID
|
||||
session: *Session,
|
||||
// Utils
|
||||
// -----
|
||||
|
||||
// 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.
|
||||
target_id: ?[]const u8,
|
||||
|
||||
// The CDP session_id. After the target/page is created, the client
|
||||
// "attaches" to it (either explicitly or automatically). We return a
|
||||
// "sessionId" which identifies this link. `sessionId` is the how
|
||||
// the CDP client informs us what it's trying to manipulate. Because we
|
||||
// only support 1 BrowserContext at a time, and 1 page at a time, this
|
||||
// is all pretty straightforward, but it still needs to be enforced, i.e.
|
||||
// if we get a request with a sessionId that doesn't match the current one
|
||||
// we should reject it.
|
||||
session_id: ?[]const u8,
|
||||
|
||||
loader_id: []const u8,
|
||||
security_origin: []const u8,
|
||||
page_life_cycle_events: bool,
|
||||
secure_context_type: []const u8,
|
||||
node_registry: Node.Registry,
|
||||
node_search_list: Node.Search.List,
|
||||
|
||||
inspector: Inspector,
|
||||
isolated_world: ?IsolatedWorld,
|
||||
http_proxy_before: ??std.Uri = null,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
fn init(self: *Self, id: []const u8, cdp: *CDP_T) !void {
|
||||
const allocator = cdp.allocator;
|
||||
|
||||
const session = try cdp.browser.newSession();
|
||||
const arena = session.arena;
|
||||
|
||||
const inspector = try cdp.browser.env.newInspector(arena, self);
|
||||
|
||||
var registry = Node.Registry.init(allocator);
|
||||
errdefer registry.deinit();
|
||||
|
||||
self.* = .{
|
||||
.id = id,
|
||||
.cdp = cdp,
|
||||
.arena = arena,
|
||||
.target_id = null,
|
||||
.session_id = null,
|
||||
.session = session,
|
||||
.security_origin = URL_BASE,
|
||||
.secure_context_type = "Secure", // TODO = enum
|
||||
.loader_id = LOADER_ID,
|
||||
.page_life_cycle_events = false, // TODO; Target based value
|
||||
.node_registry = registry,
|
||||
.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();
|
||||
|
||||
try cdp.browser.notification.register(.page_remove, self, onPageRemove);
|
||||
try cdp.browser.notification.register(.page_created, self, onPageCreated);
|
||||
try cdp.browser.notification.register(.page_navigate, self, onPageNavigate);
|
||||
try cdp.browser.notification.register(.page_navigated, self, onPageNavigated);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.inspector.deinit();
|
||||
|
||||
// If the session has a page, we need to clear it first. The page
|
||||
// context is always nested inside of the isolated world context,
|
||||
// so we need to shutdown the page one first.
|
||||
self.cdp.browser.closeSession();
|
||||
|
||||
if (self.isolated_world) |*world| {
|
||||
world.deinit();
|
||||
}
|
||||
self.node_registry.deinit();
|
||||
self.node_search_list.deinit();
|
||||
self.cdp.browser.notification.unregisterAll(self);
|
||||
|
||||
if (self.http_proxy_before) |prev_proxy| self.cdp.browser.http_client.http_proxy = prev_proxy;
|
||||
}
|
||||
|
||||
pub fn reset(self: *Self) void {
|
||||
self.node_registry.reset();
|
||||
self.node_search_list.reset();
|
||||
}
|
||||
|
||||
pub fn createIsolatedWorld(self: *Self, world_name: []const u8, grant_universal_access: bool) !*IsolatedWorld {
|
||||
if (self.isolated_world != null) {
|
||||
return error.CurrentlyOnly1IsolatedWorldSupported;
|
||||
}
|
||||
|
||||
var executor = try self.cdp.browser.env.newExecutionWorld();
|
||||
errdefer executor.deinit();
|
||||
|
||||
self.isolated_world = .{
|
||||
.name = try self.arena.dupe(u8, world_name),
|
||||
.executor = executor,
|
||||
.grant_universal_access = grant_universal_access,
|
||||
};
|
||||
return &self.isolated_world.?;
|
||||
}
|
||||
|
||||
pub fn nodeWriter(self: *Self, node: *const Node, opts: Node.Writer.Opts) Node.Writer {
|
||||
return .{
|
||||
.node = node,
|
||||
.opts = opts,
|
||||
.registry = &self.node_registry,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn getURL(self: *const Self) ?[]const u8 {
|
||||
const page = self.session.currentPage() orelse return null;
|
||||
const raw_url = page.url.raw;
|
||||
return if (raw_url.len == 0) null else raw_url;
|
||||
}
|
||||
|
||||
pub fn networkEnable(self: *Self) !void {
|
||||
try self.cdp.browser.notification.register(.http_request_fail, self, onHttpRequestFail);
|
||||
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_fail, self);
|
||||
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);
|
||||
}
|
||||
|
||||
pub fn onPageCreated(ctx: *anyopaque, page: *Page) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
return @import("domains/page.zig").pageCreated(self, page);
|
||||
}
|
||||
|
||||
pub fn onPageNavigate(ctx: *anyopaque, data: *const Notification.PageNavigate) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
defer self.resetNotificationArena();
|
||||
return @import("domains/page.zig").pageNavigate(self.notification_arena, self, data);
|
||||
}
|
||||
|
||||
pub fn onPageNavigated(ctx: *anyopaque, data: *const Notification.PageNavigated) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
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 onHttpRequestFail(ctx: *anyopaque, data: *const Notification.RequestFail) !void {
|
||||
const self: *Self = @alignCast(@ptrCast(ctx));
|
||||
defer self.resetNotificationArena();
|
||||
return @import("domains/network.zig").httpRequestFail(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.
|
||||
self.cdp.browser.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn onInspectorResponse(ctx: *anyopaque, _: u32, msg: []const u8) void {
|
||||
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
||||
log.err(.cdp, "send inspector response", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
pub fn onInspectorEvent(ctx: *anyopaque, msg: []const u8) void {
|
||||
if (log.enabled(.cdp, .debug)) {
|
||||
// msg should be {"method":<method>,...
|
||||
std.debug.assert(std.mem.startsWith(u8, msg, "{\"method\":"));
|
||||
const method_end = std.mem.indexOfScalar(u8, msg, ',') orelse {
|
||||
log.err(.cdp, "invalid inspector event", .{ .msg = msg });
|
||||
return;
|
||||
};
|
||||
const method = msg[10..method_end];
|
||||
log.debug(.cdp, "inspector event", .{ .method = method });
|
||||
}
|
||||
|
||||
sendInspectorMessage(@alignCast(@ptrCast(ctx)), msg) catch |err| {
|
||||
log.err(.cdp, "send inspector event", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
// This is hacky x 2. First, we create the JSON payload by gluing our
|
||||
// session_id onto it. Second, we're much more client/websocket aware than
|
||||
// we should be.
|
||||
fn sendInspectorMessage(self: *Self, msg: []const u8) !void {
|
||||
const session_id = self.session_id orelse {
|
||||
// We no longer have an active session. What should we do
|
||||
// in this case?
|
||||
return;
|
||||
};
|
||||
|
||||
const cdp = self.cdp;
|
||||
var arena = std.heap.ArenaAllocator.init(cdp.allocator);
|
||||
errdefer arena.deinit();
|
||||
|
||||
const field = ",\"sessionId\":\"";
|
||||
|
||||
// + 1 for the closing quote after the session id
|
||||
// + 10 for the max websocket header
|
||||
const message_len = msg.len + session_id.len + 1 + field.len + 10;
|
||||
|
||||
var buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
buf.ensureTotalCapacity(arena.allocator(), message_len) catch |err| {
|
||||
log.err(.cdp, "inspector buffer", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
|
||||
// reserve 10 bytes for websocket header
|
||||
buf.appendSliceAssumeCapacity(&.{ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 });
|
||||
|
||||
// -1 because we dont' want the closing brace '}'
|
||||
buf.appendSliceAssumeCapacity(msg[0 .. msg.len - 1]);
|
||||
buf.appendSliceAssumeCapacity(field);
|
||||
buf.appendSliceAssumeCapacity(session_id);
|
||||
buf.appendSliceAssumeCapacity("\"}");
|
||||
std.debug.assert(buf.items.len == message_len);
|
||||
|
||||
try cdp.client.sendJSONRaw(arena, buf);
|
||||
}
|
||||
};
|
||||
pub fn dumpFile(
|
||||
alloc: std.mem.Allocator,
|
||||
id: u16,
|
||||
script: []const u8,
|
||||
) !void {
|
||||
const name = try std.fmt.allocPrint(alloc, "id_{d}.js", .{id});
|
||||
defer alloc.free(name);
|
||||
var dir = try std.fs.cwd().makeOpenPath("zig-cache/tmp", .{});
|
||||
defer dir.close();
|
||||
const f = try dir.createFile(name, .{});
|
||||
defer f.close();
|
||||
const nb = try f.write(script);
|
||||
std.debug.assert(nb == script.len);
|
||||
const p = try dir.realpathAlloc(alloc, name);
|
||||
defer alloc.free(p);
|
||||
}
|
||||
|
||||
/// see: https://chromium.googlesource.com/chromium/src/+/master/third_party/blink/renderer/bindings/core/v8/V8BindingDesign.md#world
|
||||
/// The current understanding. An isolated world lives in the same isolate, but a separated context.
|
||||
/// Clients create this to be able to create variables and run code without interfering with the
|
||||
/// normal namespace and values of the webpage. Similar to the main context we need to pretend to recreate it after
|
||||
/// a executionContextsCleared event which happens when navigating to a new page. A client can have a command be executed
|
||||
/// in the isolated world by using its Context ID or the worldName.
|
||||
/// grantUniveralAccess Indecated whether the isolated world can reference objects like the DOM or other JS Objects.
|
||||
/// An isolated world has it's own instance of globals like Window.
|
||||
/// Generally the client needs to resolve a node into the isolated world to be able to work with it.
|
||||
/// An object id is unique across all contexts, different object ids can refer to the same Node in different contexts.
|
||||
const IsolatedWorld = struct {
|
||||
// caller owns the slice returned
|
||||
pub fn stringify(alloc: std.mem.Allocator, res: anytype) ![]const u8 {
|
||||
var out = std.ArrayList(u8).init(alloc);
|
||||
defer out.deinit();
|
||||
|
||||
// Do not emit optional null fields
|
||||
const options: std.json.StringifyOptions = .{ .emit_null_optional_fields = false };
|
||||
|
||||
try std.json.stringify(res, options, out.writer());
|
||||
const ret = try alloc.alloc(u8, out.items.len);
|
||||
@memcpy(ret, out.items);
|
||||
return ret;
|
||||
}
|
||||
|
||||
const resultNull = "{{\"id\": {d}, \"result\": {{}}}}";
|
||||
const resultNullSession = "{{\"id\": {d}, \"result\": {{}}, \"sessionId\": \"{s}\"}}";
|
||||
|
||||
// caller owns the slice returned
|
||||
pub fn result(
|
||||
alloc: std.mem.Allocator,
|
||||
id: u16,
|
||||
comptime T: ?type,
|
||||
res: anytype,
|
||||
sessionID: ?[]const u8,
|
||||
) ![]const u8 {
|
||||
log_cdp.debug(
|
||||
"Res > id {d}, sessionID {?s}, result {any}",
|
||||
.{ id, sessionID, res },
|
||||
);
|
||||
if (T == null) {
|
||||
// No need to stringify a custom JSON msg, just use string templates
|
||||
if (sessionID) |sID| {
|
||||
return try std.fmt.allocPrint(alloc, resultNullSession, .{ id, sID });
|
||||
}
|
||||
return try std.fmt.allocPrint(alloc, resultNull, .{id});
|
||||
}
|
||||
|
||||
const Resp = struct {
|
||||
id: u16,
|
||||
result: T.?,
|
||||
sessionId: ?[]const u8,
|
||||
};
|
||||
const resp = Resp{ .id = id, .result = res, .sessionId = sessionID };
|
||||
|
||||
return stringify(alloc, resp);
|
||||
}
|
||||
|
||||
pub fn sendEvent(
|
||||
alloc: std.mem.Allocator,
|
||||
ctx: *Ctx,
|
||||
name: []const u8,
|
||||
executor: Env.ExecutionWorld,
|
||||
grant_universal_access: bool,
|
||||
|
||||
// Polyfill loader for the isolated world.
|
||||
// We want to load polyfill in the world's context.
|
||||
polyfill_loader: polyfill.Loader = .{},
|
||||
|
||||
pub fn deinit(self: *IsolatedWorld) void {
|
||||
self.executor.deinit();
|
||||
}
|
||||
pub fn removeContext(self: *IsolatedWorld) !void {
|
||||
if (self.executor.js_context == null) return error.NoIsolatedContextToRemove;
|
||||
self.executor.removeJsContext();
|
||||
}
|
||||
|
||||
// The isolate world must share at least some of the state with the related page, specifically the DocumentHTML
|
||||
// (assuming grantUniveralAccess will be set to True!).
|
||||
// We just created the world and the page. The page's state lives in the session, but is update on navigation.
|
||||
// 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.executor.js_context != null) return error.Only1IsolatedContextSupported;
|
||||
_ = try self.executor.createJsContext(
|
||||
&page.window,
|
||||
page,
|
||||
{},
|
||||
false,
|
||||
.{
|
||||
.global_callback = Env.GlobalMissingCallback.init(&self.polyfill_loader),
|
||||
.compilation_callback = Env.CompilationCallback.init(&self.polyfill_loader),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// This is a generic because when we send a result we have two different
|
||||
// behaviors. Normally, we're sending the result to the client. But in some cases
|
||||
// we want to capture the result. So we want the command.sendResult to be
|
||||
// generic.
|
||||
pub fn Command(comptime CDP_T: type, comptime Sender: type) type {
|
||||
return struct {
|
||||
// A misc arena that can be used for any allocation for processing
|
||||
// the message
|
||||
arena: Allocator,
|
||||
|
||||
// reference to our CDP instance
|
||||
cdp: *CDP_T,
|
||||
|
||||
// The browser context this command targets
|
||||
browser_context: ?*BrowserContext(CDP_T),
|
||||
|
||||
// The command input (the id, optional session_id, params, ...)
|
||||
input: Input,
|
||||
|
||||
// In most cases, Sender is going to be cdp itself. We'll call
|
||||
// sender.sendJSON() and CDP will send it to the client. But some
|
||||
// comamnds are dispatched internally, in which cases the Sender will
|
||||
// be code to capture the data that we were "sending".
|
||||
sender: Sender,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
pub fn params(self: *const Self, comptime T: type) !?T {
|
||||
if (self.input.params) |p| {
|
||||
return try json.parseFromSliceLeaky(
|
||||
T,
|
||||
self.arena,
|
||||
p.raw,
|
||||
.{ .ignore_unknown_fields = true },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn createBrowserContext(self: *Self) !*BrowserContext(CDP_T) {
|
||||
_ = try self.cdp.createBrowserContext();
|
||||
self.browser_context = &(self.cdp.browser_context.?);
|
||||
return self.browser_context.?;
|
||||
}
|
||||
|
||||
const SendResultOpts = struct {
|
||||
include_session_id: bool = true,
|
||||
};
|
||||
pub fn sendResult(self: *Self, result: anytype, opts: SendResultOpts) !void {
|
||||
return self.sender.sendJSON(.{
|
||||
.id = self.input.id,
|
||||
.result = if (comptime @typeInfo(@TypeOf(result)) == .null) struct {}{} else result,
|
||||
.sessionId = if (opts.include_session_id) self.input.session_id else null,
|
||||
});
|
||||
}
|
||||
|
||||
const SendEventOpts = struct {
|
||||
session_id: ?[]const u8 = null,
|
||||
};
|
||||
pub fn sendEvent(self: *Self, method: []const u8, p: anytype, opts: CDP_T.SendEventOpts) !void {
|
||||
// Events ALWAYS go to the client. self.sender should not be used
|
||||
return self.cdp.sendEvent(method, p, opts);
|
||||
}
|
||||
|
||||
pub fn sendError(self: *Self, code: i32, message: []const u8) !void {
|
||||
return self.sender.sendJSON(.{
|
||||
.id = self.input.id,
|
||||
.code = code,
|
||||
.message = message,
|
||||
});
|
||||
}
|
||||
|
||||
const Input = struct {
|
||||
// When we reply to a message, we echo back the message id
|
||||
id: ?i64,
|
||||
|
||||
// The "action" of the message.Given a method of "LOG.enable", the
|
||||
// action is "enable"
|
||||
action: []const u8,
|
||||
|
||||
// See notes in BrowserContext about session_id
|
||||
session_id: ?[]const u8,
|
||||
|
||||
// Unparsed / untyped input.params.
|
||||
params: ?InputParams,
|
||||
|
||||
// The full raw json input
|
||||
json: []const u8,
|
||||
};
|
||||
comptime T: type,
|
||||
params: T,
|
||||
sessionID: ?[]const u8,
|
||||
) !void {
|
||||
log_cdp.debug("Event > method {s}, sessionID {?s}", .{ name, sessionID });
|
||||
const Resp = struct {
|
||||
method: []const u8,
|
||||
params: T,
|
||||
sessionId: ?[]const u8,
|
||||
};
|
||||
const resp = Resp{ .method = name, .params = params, .sessionId = sessionID };
|
||||
|
||||
const event_msg = try stringify(alloc, resp);
|
||||
try ctx.send(event_msg);
|
||||
}
|
||||
|
||||
// When we parse a JSON message from the client, this is the structure
|
||||
// we always expect
|
||||
const InputMessage = struct {
|
||||
id: ?i64 = null,
|
||||
method: []const u8,
|
||||
params: ?InputParams = null,
|
||||
sessionId: ?[]const u8 = null,
|
||||
// Common
|
||||
// ------
|
||||
|
||||
// TODO: hard coded IDs
|
||||
pub const BrowserSessionID = "BROWSERSESSIONID597D9875C664CAC0";
|
||||
pub const ContextSessionID = "CONTEXTSESSIONID0497A05C95417CF4";
|
||||
pub const URLBase = "chrome://newtab/";
|
||||
pub const LoaderID = "LOADERID24DD2FD56CF1EF33C965C79C";
|
||||
pub const FrameID = "FRAMEIDD8AED408A0467AC93100BCDBE";
|
||||
|
||||
pub const TimestampEvent = struct {
|
||||
timestamp: f64,
|
||||
};
|
||||
|
||||
// The JSON "params" field changes based on the "method". Initially, we just
|
||||
// capture the raw json object (including the opening and closing braces).
|
||||
// Then, when we're processing the message, and we know what type it is, we
|
||||
// can parse it (in Disaptch(T).params).
|
||||
const InputParams = struct {
|
||||
raw: []const u8,
|
||||
|
||||
pub fn jsonParse(
|
||||
_: Allocator,
|
||||
scanner: *json.Scanner,
|
||||
_: json.ParseOptions,
|
||||
) !InputParams {
|
||||
const height = scanner.stackHeight();
|
||||
|
||||
const start = scanner.cursor;
|
||||
if (try scanner.next() != .object_begin) {
|
||||
return error.UnexpectedToken;
|
||||
}
|
||||
try scanner.skipUntilStackHeight(height);
|
||||
const end = scanner.cursor;
|
||||
|
||||
return .{ .raw = scanner.input[start..end] };
|
||||
}
|
||||
};
|
||||
|
||||
fn asUint(comptime T: type, comptime string: []const u8) T {
|
||||
return @bitCast(string[0..string.len].*);
|
||||
}
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
test "cdp: invalid json" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try testing.expectError(error.InvalidJSON, ctx.processMessage("invalid"));
|
||||
|
||||
// method is required
|
||||
try testing.expectError(error.InvalidJSON, ctx.processMessage(.{}));
|
||||
|
||||
try testing.expectError(error.InvalidMethod, ctx.processMessage(.{
|
||||
.method = "Target",
|
||||
}));
|
||||
try ctx.expectSentError(-31998, "InvalidMethod", .{});
|
||||
|
||||
try testing.expectError(error.UnknownDomain, ctx.processMessage(.{
|
||||
.method = "Unknown.domain",
|
||||
}));
|
||||
|
||||
try testing.expectError(error.UnknownMethod, ctx.processMessage(.{
|
||||
.method = "Target.over9000",
|
||||
}));
|
||||
}
|
||||
|
||||
test "cdp: invalid sessionId" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
// we have no browser context
|
||||
try ctx.processMessage(.{ .method = "Hi", .sessionId = "nope" });
|
||||
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
||||
}
|
||||
|
||||
{
|
||||
// we have a brower context but no session_id
|
||||
_ = try ctx.loadBrowserContext(.{});
|
||||
try ctx.processMessage(.{ .method = "Hi", .sessionId = "BC-Has-No-SessionId" });
|
||||
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
||||
}
|
||||
|
||||
{
|
||||
// we have a brower context with a different session_id
|
||||
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
|
||||
try ctx.processMessage(.{ .method = "Hi", .sessionId = "SESS-1" });
|
||||
try ctx.expectSentError(-32001, "Unknown sessionId", .{});
|
||||
}
|
||||
}
|
||||
|
||||
test "cdp: STARTUP sessionId" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
// we have no browser context
|
||||
try ctx.processMessage(.{ .id = 2, .method = "Hi", .sessionId = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 2, .index = 0, .session_id = "STARTUP" });
|
||||
}
|
||||
|
||||
{
|
||||
// we have a brower context but no session_id
|
||||
_ = try ctx.loadBrowserContext(.{});
|
||||
try ctx.processMessage(.{ .id = 3, .method = "Hi", .sessionId = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 3, .index = 0, .session_id = "STARTUP" });
|
||||
}
|
||||
|
||||
{
|
||||
// we have a brower context with a different session_id
|
||||
_ = try ctx.loadBrowserContext(.{ .session_id = "SESS-2" });
|
||||
try ctx.processMessage(.{ .id = 4, .method = "Hi", .sessionId = "STARTUP" });
|
||||
try ctx.expectSentResult(null, .{ .id = 4, .index = 0, .session_id = "STARTUP" });
|
||||
}
|
||||
}
|
||||
|
||||
59
src/cdp/css.zig
Normal file
59
src/cdp/css.zig
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 server = @import("../server.zig");
|
||||
const Ctx = server.Ctx;
|
||||
const cdp = @import("cdp.zig");
|
||||
const result = cdp.result;
|
||||
const IncomingMessage = @import("msg.zig").IncomingMessage;
|
||||
const Input = @import("msg.zig").Input;
|
||||
|
||||
const log = std.log.scoped(.cdp);
|
||||
|
||||
const Methods = enum {
|
||||
enable,
|
||||
};
|
||||
|
||||
pub fn css(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
action: []const u8,
|
||||
ctx: *Ctx,
|
||||
) ![]const u8 {
|
||||
const method = std.meta.stringToEnum(Methods, action) orelse
|
||||
return error.UnknownMethod;
|
||||
|
||||
return switch (method) {
|
||||
.enable => enable(alloc, msg, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
fn enable(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
// input
|
||||
const input = try Input(void).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
log.debug("Req > id {d}, method {s}", .{ input.id, "inspector.enable" });
|
||||
|
||||
return result(alloc, input.id, null, null, input.sessionId);
|
||||
}
|
||||
59
src/cdp/dom.zig
Normal file
59
src/cdp/dom.zig
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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 server = @import("../server.zig");
|
||||
const Ctx = server.Ctx;
|
||||
const cdp = @import("cdp.zig");
|
||||
const result = cdp.result;
|
||||
const IncomingMessage = @import("msg.zig").IncomingMessage;
|
||||
const Input = @import("msg.zig").Input;
|
||||
|
||||
const log = std.log.scoped(.cdp);
|
||||
|
||||
const Methods = enum {
|
||||
enable,
|
||||
};
|
||||
|
||||
pub fn dom(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
action: []const u8,
|
||||
ctx: *Ctx,
|
||||
) ![]const u8 {
|
||||
const method = std.meta.stringToEnum(Methods, action) orelse
|
||||
return error.UnknownMethod;
|
||||
|
||||
return switch (method) {
|
||||
.enable => enable(alloc, msg, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
fn enable(
|
||||
alloc: std.mem.Allocator,
|
||||
msg: *IncomingMessage,
|
||||
_: *Ctx,
|
||||
) ![]const u8 {
|
||||
// input
|
||||
const input = try Input(void).get(alloc, msg);
|
||||
defer input.deinit();
|
||||
log.debug("Req > id {d}, method {s}", .{ input.id, "inspector.enable" });
|
||||
|
||||
return result(alloc, input.id, null, null, input.sessionId);
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// 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");
|
||||
|
||||
// TODO: hard coded data
|
||||
const PROTOCOL_VERSION = "1.3";
|
||||
const PRODUCT = "Chrome/124.0.6367.29";
|
||||
const REVISION = "@9e6ded5ac1ff5e38d930ae52bd9aec09bd1a68e4";
|
||||
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
|
||||
const JS_VERSION = "12.4.254.8";
|
||||
const DEV_TOOLS_WINDOW_ID = 1923710101;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
getVersion,
|
||||
setDownloadBehavior,
|
||||
getWindowForTarget,
|
||||
setWindowBounds,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.getVersion => return getVersion(cmd),
|
||||
.setDownloadBehavior => return setDownloadBehavior(cmd),
|
||||
.getWindowForTarget => return getWindowForTarget(cmd),
|
||||
.setWindowBounds => return setWindowBounds(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
fn getVersion(cmd: anytype) !void {
|
||||
// TODO: pre-serialize?
|
||||
return cmd.sendResult(.{
|
||||
.protocolVersion = PROTOCOL_VERSION,
|
||||
.product = PRODUCT,
|
||||
.revision = REVISION,
|
||||
.userAgent = USER_AGENT,
|
||||
.jsVersion = JS_VERSION,
|
||||
}, .{ .include_session_id = false });
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setDownloadBehavior(cmd: anytype) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// behavior: []const u8,
|
||||
// browserContextId: ?[]const u8 = null,
|
||||
// downloadPath: ?[]const u8 = null,
|
||||
// eventsEnabled: ?bool = null,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
return cmd.sendResult(null, .{ .include_session_id = false });
|
||||
}
|
||||
|
||||
fn getWindowForTarget(cmd: anytype) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// targetId: ?[]const u8 = null,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
return cmd.sendResult(.{ .windowId = DEV_TOOLS_WINDOW_ID, .bounds = .{
|
||||
.windowState = "normal",
|
||||
} }, .{});
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setWindowBounds(cmd: anytype) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.browser: getVersion" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 32,
|
||||
.method = "Browser.getVersion",
|
||||
});
|
||||
|
||||
try ctx.expectSentCount(1);
|
||||
try ctx.expectSentResult(.{
|
||||
.protocolVersion = PROTOCOL_VERSION,
|
||||
.product = PRODUCT,
|
||||
.revision = REVISION,
|
||||
.userAgent = USER_AGENT,
|
||||
.jsVersion = JS_VERSION,
|
||||
}, .{ .id = 32, .index = 0, .session_id = null });
|
||||
}
|
||||
|
||||
test "cdp.browser: getWindowForTarget" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 33,
|
||||
.method = "Browser.getWindowForTarget",
|
||||
});
|
||||
|
||||
try ctx.expectSentCount(1);
|
||||
try ctx.expectSentResult(.{
|
||||
.windowId = DEV_TOOLS_WINDOW_ID,
|
||||
.bounds = .{ .windowState = "normal" },
|
||||
}, .{ .id = 33, .index = 0, .session_id = null });
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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 fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
@@ -1,609 +0,0 @@
|
||||
// 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 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 {
|
||||
enable,
|
||||
getDocument,
|
||||
performSearch,
|
||||
getSearchResults,
|
||||
discardSearchResults,
|
||||
querySelector,
|
||||
querySelectorAll,
|
||||
resolveNode,
|
||||
describeNode,
|
||||
scrollIntoViewIfNeeded,
|
||||
getContentQuads,
|
||||
getBoxModel,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.getDocument => return getDocument(cmd),
|
||||
.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),
|
||||
}
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getDocument
|
||||
fn getDocument(cmd: anytype) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// depth: ?u32 = null,
|
||||
// pierce: ?bool = null,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const doc = parser.documentHTMLToDocument(page.window.document);
|
||||
|
||||
const node = try bc.node_registry.register(parser.documentToNode(doc));
|
||||
return cmd.sendResult(.{ .root = bc.nodeWriter(node, .{}) }, .{});
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-performSearch
|
||||
fn performSearch(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
query: []const u8,
|
||||
includeUserAgentShadowDOM: ?bool = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const doc = parser.documentHTMLToDocument(page.window.document);
|
||||
|
||||
const allocator = cmd.cdp.allocator;
|
||||
var list = try css.querySelectorAll(allocator, parser.documentToNode(doc), params.query);
|
||||
defer list.deinit(allocator);
|
||||
|
||||
const search = try bc.node_search_list.create(list.nodes.items);
|
||||
|
||||
// dispatch setChildNodesEvents to inform the client of the subpart of node
|
||||
// tree covering the results.
|
||||
try dispatchSetChildNodes(cmd, list.nodes.items);
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.searchId = search.name,
|
||||
.resultCount = @as(u32, @intCast(search.node_ids.len)),
|
||||
}, .{});
|
||||
}
|
||||
|
||||
// dispatchSetChildNodes send the setChildNodes event for the whole DOM tree
|
||||
// hierarchy of each nodes.
|
||||
// We dispatch event in the reverse order: from the top level to the direct parents.
|
||||
// We should dispatch a node only if it has never been sent.
|
||||
fn dispatchSetChildNodes(cmd: anytype, nodes: []*parser.Node) !void {
|
||||
const arena = cmd.arena;
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const session_id = bc.session_id orelse return error.SessionIdNotLoaded;
|
||||
|
||||
var parents: std.ArrayListUnmanaged(*Node) = .{};
|
||||
for (nodes) |_n| {
|
||||
var n = _n;
|
||||
while (true) {
|
||||
const p = try parser.nodeParentNode(n) orelse break;
|
||||
|
||||
// Register the node.
|
||||
const node = try bc.node_registry.register(p);
|
||||
if (node.set_child_nodes_event) break;
|
||||
try parents.append(arena, node);
|
||||
n = p;
|
||||
}
|
||||
}
|
||||
|
||||
const plen = parents.items.len;
|
||||
if (plen == 0) return;
|
||||
|
||||
var i: usize = plen;
|
||||
// We're going to iterate in reverse order from how we added them.
|
||||
// This ensures that we're emitting the tree of nodes top-down.
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
const node = parents.items[i];
|
||||
// Although our above loop won't add an already-sent node to `parents`
|
||||
// this can still be true because two nodes can share the same parent node
|
||||
// so we might have just sent the node a previous iteration of this loop
|
||||
if (node.set_child_nodes_event) continue;
|
||||
|
||||
node.set_child_nodes_event = true;
|
||||
|
||||
// If the node has no parent, it's the root node.
|
||||
// We don't dispatch event for it because we assume the root node is
|
||||
// dispatched via the DOM.getDocument command.
|
||||
const p = try parser.nodeParentNode(node._node) orelse {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Retrieve the parent from the registry.
|
||||
const parent_node = try bc.node_registry.register(p);
|
||||
|
||||
try cmd.sendEvent("DOM.setChildNodes", .{
|
||||
.parentId = parent_node.id,
|
||||
.nodes = .{bc.nodeWriter(node, .{})},
|
||||
}, .{
|
||||
.session_id = session_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-discardSearchResults
|
||||
fn discardSearchResults(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
searchId: []const u8,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
bc.node_search_list.remove(params.searchId);
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/DOM/#method-getSearchResults
|
||||
fn getSearchResults(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
searchId: []const u8,
|
||||
fromIndex: u32,
|
||||
toIndex: u32,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
if (params.fromIndex >= params.toIndex) {
|
||||
return error.BadIndices;
|
||||
}
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const search = bc.node_search_list.get(params.searchId) orelse {
|
||||
return error.SearchResultNotFound;
|
||||
};
|
||||
|
||||
const node_ids = search.node_ids;
|
||||
|
||||
if (params.fromIndex >= node_ids.len) return error.BadFromIndex;
|
||||
if (params.toIndex > node_ids.len) return error.BadToIndex;
|
||||
|
||||
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,
|
||||
backendNodeId: ?u32 = null,
|
||||
objectGroup: ?[]const u8 = null,
|
||||
executionContextId: ?u32 = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
var js_context = page.main_context;
|
||||
if (params.executionContextId) |context_id| {
|
||||
if (js_context.v8_context.debugContextId() != context_id) {
|
||||
var isolated_world = bc.isolated_world orelse return error.ContextNotFound;
|
||||
js_context = &(isolated_world.executor.js_context orelse return error.ContextNotFound);
|
||||
|
||||
if (js_context.v8_context.debugContextId() != context_id) return error.ContextNotFound;
|
||||
}
|
||||
}
|
||||
|
||||
const input_node_id = params.nodeId orelse params.backendNodeId orelse return error.InvalidParam;
|
||||
const node = bc.node_registry.lookup_by_id.get(input_node_id) orelse return error.UnknownNode;
|
||||
|
||||
// node._node is a *parser.Node we need this to be able to find its most derived type e.g. Node -> Element -> HTMLElement
|
||||
// So we use the Node.Union when retrieve the value from the environment
|
||||
const remote_object = try bc.inspector.getRemoteObject(
|
||||
js_context,
|
||||
params.objectGroup orelse "",
|
||||
try dom_node.Node.toInterface(node._node),
|
||||
);
|
||||
defer remote_object.deinit();
|
||||
|
||||
const arena = cmd.arena;
|
||||
return cmd.sendResult(.{ .object = .{
|
||||
.type = try remote_object.getType(arena),
|
||||
.subtype = try remote_object.getSubtype(arena),
|
||||
.className = try remote_object.getClassName(arena),
|
||||
.description = try remote_object.getDescription(arena),
|
||||
.objectId = try remote_object.getObjectId(arena),
|
||||
} }, .{});
|
||||
}
|
||||
|
||||
fn describeNode(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
nodeId: ?Node.Id = null,
|
||||
backendNodeId: ?Node.Id = null,
|
||||
objectId: ?[]const u8 = null,
|
||||
depth: u32 = 1,
|
||||
pierce: bool = false,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
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(@alignCast(@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;
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
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, page);
|
||||
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 page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
|
||||
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, page);
|
||||
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");
|
||||
|
||||
test "cdp.dom: getSearchResults unknown search id" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{
|
||||
.id = 8,
|
||||
.method = "DOM.getSearchResults",
|
||||
.params = .{ .searchId = "Nope", .fromIndex = 0, .toIndex = 10 },
|
||||
}));
|
||||
}
|
||||
|
||||
test "cdp.dom: search flow" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-A", .html = "<p>1</p> <p>2</p>" });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 12,
|
||||
.method = "DOM.performSearch",
|
||||
.params = .{ .query = "p" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .searchId = "0", .resultCount = 2 }, .{ .id = 12 });
|
||||
|
||||
{
|
||||
// getSearchResults
|
||||
try ctx.processMessage(.{
|
||||
.id = 13,
|
||||
.method = "DOM.getSearchResults",
|
||||
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 2 },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeIds = &.{ 0, 1 } }, .{ .id = 13 });
|
||||
|
||||
// different fromIndex
|
||||
try ctx.processMessage(.{
|
||||
.id = 14,
|
||||
.method = "DOM.getSearchResults",
|
||||
.params = .{ .searchId = "0", .fromIndex = 1, .toIndex = 2 },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeIds = &.{1} }, .{ .id = 14 });
|
||||
|
||||
// different toIndex
|
||||
try ctx.processMessage(.{
|
||||
.id = 15,
|
||||
.method = "DOM.getSearchResults",
|
||||
.params = .{ .searchId = "0", .fromIndex = 0, .toIndex = 1 },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .nodeIds = &.{0} }, .{ .id = 15 });
|
||||
}
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 16,
|
||||
.method = "DOM.discardSearchResults",
|
||||
.params = .{ .searchId = "0" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 16 });
|
||||
|
||||
// make sure the delete actually did something
|
||||
try testing.expectError(error.SearchResultNotFound, ctx.processMessage(.{
|
||||
.id = 17,
|
||||
.method = "DOM.getSearchResults",
|
||||
.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 });
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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 fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
setEmulatedMedia,
|
||||
setFocusEmulationEnabled,
|
||||
setDeviceMetricsOverride,
|
||||
setTouchEmulationEnabled,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.setEmulatedMedia => return setEmulatedMedia(cmd),
|
||||
.setFocusEmulationEnabled => return setFocusEmulationEnabled(cmd),
|
||||
.setDeviceMetricsOverride => return setDeviceMetricsOverride(cmd),
|
||||
.setTouchEmulationEnabled => return setTouchEmulationEnabled(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setEmulatedMedia(cmd: anytype) !void {
|
||||
// const input = (try const incoming.params(struct {
|
||||
// media: ?[]const u8 = null,
|
||||
// features: ?[]struct{
|
||||
// name: []const u8,
|
||||
// value: [] const u8
|
||||
// } = null,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setFocusEmulationEnabled(cmd: anytype) !void {
|
||||
// const input = (try const incoming.params(struct {
|
||||
// enabled: bool,
|
||||
// })) orelse return error.InvalidParams;
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setDeviceMetricsOverride(cmd: anytype) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
// TODO: noop method
|
||||
fn setTouchEmulationEnabled(cmd: anytype) !void {
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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 fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
disable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.disable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// 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 Page = @import("../../browser/page.zig").Page;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
dispatchKeyEvent,
|
||||
dispatchMouseEvent,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.dispatchKeyEvent => return dispatchKeyEvent(cmd),
|
||||
.dispatchMouseEvent => return dispatchMouseEvent(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchKeyEvent
|
||||
fn dispatchKeyEvent(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
type: Type,
|
||||
key: []const u8 = "",
|
||||
code: []const u8 = "",
|
||||
modifiers: u4 = 0,
|
||||
// Many optional parameters are not implemented yet, see documentation url.
|
||||
|
||||
const Type = enum {
|
||||
keyDown,
|
||||
keyUp,
|
||||
rawKeyDown,
|
||||
char,
|
||||
};
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
|
||||
// quickly ignore types we know we don't handle
|
||||
switch (params.type) {
|
||||
.keyUp, .rawKeyDown, .char => return,
|
||||
.keyDown => {},
|
||||
}
|
||||
|
||||
const bc = cmd.browser_context orelse return;
|
||||
const page = bc.session.currentPage() orelse return;
|
||||
|
||||
const keyboard_event = Page.KeyboardEvent{
|
||||
.key = params.key,
|
||||
.code = params.code,
|
||||
.type = switch (params.type) {
|
||||
.keyDown => .keydown,
|
||||
else => unreachable,
|
||||
},
|
||||
.alt = params.modifiers & 1 == 1,
|
||||
.ctrl = params.modifiers & 2 == 2,
|
||||
.meta = params.modifiers & 4 == 4,
|
||||
.shift = params.modifiers & 8 == 8,
|
||||
};
|
||||
try page.keyboardEvent(keyboard_event);
|
||||
// result already sent
|
||||
}
|
||||
|
||||
// https://chromedevtools.github.io/devtools-protocol/tot/Input/#method-dispatchMouseEvent
|
||||
fn dispatchMouseEvent(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
type: Type, // Type of the mouse event.
|
||||
x: f32, // X coordinate of the event relative to the main frame's viewport.
|
||||
y: f32, // Y coordinate of the event relative to the main frame's viewport. 0 refers to the top of the viewport and Y increases as it proceeds towards the bottom of the viewport.
|
||||
// Many optional parameters are not implemented yet, see documentation url.
|
||||
|
||||
const Type = enum {
|
||||
mousePressed,
|
||||
mouseReleased,
|
||||
mouseMoved,
|
||||
mouseWheel,
|
||||
};
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
|
||||
// quickly ignore types we know we don't handle
|
||||
switch (params.type) {
|
||||
.mouseMoved, .mouseWheel => return,
|
||||
else => {},
|
||||
}
|
||||
|
||||
const bc = cmd.browser_context orelse return;
|
||||
const page = bc.session.currentPage() orelse return;
|
||||
|
||||
const mouse_event = Page.MouseEvent{
|
||||
.x = @intFromFloat(@floor(params.x)), // Decimal pixel values are not understood by netsurf or our renderer
|
||||
.y = @intFromFloat(@floor(params.y)), // So we convert them once at intake here. Using floor such that -0.5 becomes -1 and 0.5 becomes 0.
|
||||
.type = switch (params.type) {
|
||||
.mousePressed => .pressed,
|
||||
.mouseReleased => .released,
|
||||
else => unreachable,
|
||||
},
|
||||
};
|
||||
try page.mouseEvent(mouse_event);
|
||||
// result already sent
|
||||
}
|
||||
|
||||
fn clickNavigate(cmd: anytype, uri: std.Uri) !void {
|
||||
const bc = cmd.browser_context.?;
|
||||
|
||||
var url_buf: std.ArrayListUnmanaged(u8) = .{};
|
||||
try uri.writeToStream(.{
|
||||
.scheme = true,
|
||||
.authentication = true,
|
||||
.authority = true,
|
||||
.port = true,
|
||||
.path = true,
|
||||
.query = true,
|
||||
}, url_buf.writer(cmd.arena));
|
||||
const url = url_buf.items;
|
||||
|
||||
try cmd.sendEvent("Page.frameRequestedNavigation", .{
|
||||
.url = url,
|
||||
.frameId = bc.target_id.?,
|
||||
.reason = "anchorClick",
|
||||
.disposition = "currentTab",
|
||||
}, .{ .session_id = bc.session_id.? });
|
||||
|
||||
try bc.session.removePage();
|
||||
_ = try bc.session.createPage(null);
|
||||
|
||||
try @import("page.zig").navigateToUrl(cmd, url, false);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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 fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
// 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 Notification = @import("../../notification.zig").Notification;
|
||||
const log = @import("../../log.zig");
|
||||
const CdpStorage = @import("storage.zig");
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
disable,
|
||||
setCacheDisabled,
|
||||
setExtraHTTPHeaders,
|
||||
deleteCookies,
|
||||
clearBrowserCookies,
|
||||
setCookie,
|
||||
setCookies,
|
||||
getCookies,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return enable(cmd),
|
||||
.disable => return disable(cmd),
|
||||
.setCacheDisabled => return cmd.sendResult(null, .{}),
|
||||
.setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd),
|
||||
.deleteCookies => return deleteCookies(cmd),
|
||||
.clearBrowserCookies => return clearBrowserCookies(cmd),
|
||||
.setCookie => return setCookie(cmd),
|
||||
.setCookies => return setCookies(cmd),
|
||||
.getCookies => return getCookies(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
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, .{});
|
||||
}
|
||||
|
||||
fn setExtraHTTPHeaders(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
headers: std.json.ArrayHashMap([]const u8),
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
// Copy the headers onto the browser context arena
|
||||
const arena = bc.arena;
|
||||
const extra_headers = &bc.cdp.extra_headers;
|
||||
|
||||
extra_headers.clearRetainingCapacity();
|
||||
try extra_headers.ensureTotalCapacity(arena, params.headers.map.count());
|
||||
var it = params.headers.map.iterator();
|
||||
while (it.next()) |header| {
|
||||
extra_headers.appendAssumeCapacity(.{ .name = try arena.dupe(u8, header.key_ptr.*), .value = try arena.dupe(u8, header.value_ptr.*) });
|
||||
}
|
||||
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const Cookie = @import("../../browser/storage/storage.zig").Cookie;
|
||||
|
||||
// Only matches the cookie on provided parameters
|
||||
fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool {
|
||||
if (!std.mem.eql(u8, cookie.name, name)) return false;
|
||||
|
||||
if (domain) |domain_| {
|
||||
const c_no_dot = if (std.mem.startsWith(u8, cookie.domain, ".")) cookie.domain[1..] else cookie.domain;
|
||||
const d_no_dot = if (std.mem.startsWith(u8, domain_, ".")) domain_[1..] else domain_;
|
||||
if (!std.mem.eql(u8, c_no_dot, d_no_dot)) return false;
|
||||
}
|
||||
if (path) |path_| {
|
||||
if (!std.mem.eql(u8, cookie.path, path_)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn deleteCookies(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
name: []const u8,
|
||||
url: ?[]const u8 = null,
|
||||
domain: ?[]const u8 = null,
|
||||
path: ?[]const u8 = null,
|
||||
partitionKey: ?CdpStorage.CookiePartitionKey = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
if (params.partitionKey != null) return error.NotYetImplementedParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const cookies = &bc.session.cookie_jar.cookies;
|
||||
|
||||
const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null;
|
||||
const uri_ptr = if (uri) |u| &u else null;
|
||||
|
||||
var index = cookies.items.len;
|
||||
while (index > 0) {
|
||||
index -= 1;
|
||||
const cookie = &cookies.items[index];
|
||||
const domain = try Cookie.parseDomain(cmd.arena, uri_ptr, params.domain);
|
||||
const path = try Cookie.parsePath(cmd.arena, uri_ptr, params.path);
|
||||
|
||||
// We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match.
|
||||
// Similar to deduplicating with areCookiesEqual, except domain and path are optional.
|
||||
if (cookieMatches(cookie, params.name, domain, path)) {
|
||||
cookies.swapRemove(index).deinit();
|
||||
}
|
||||
}
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn clearBrowserCookies(cmd: anytype) !void {
|
||||
if (try cmd.params(struct {}) != null) return error.InvalidParams;
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
bc.session.cookie_jar.clearRetainingCapacity();
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
fn setCookie(cmd: anytype) !void {
|
||||
const params = (try cmd.params(
|
||||
CdpStorage.CdpCookie,
|
||||
)) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params);
|
||||
|
||||
try cmd.sendResult(.{ .success = true }, .{});
|
||||
}
|
||||
|
||||
fn setCookies(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
cookies: []const CdpStorage.CdpCookie,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
for (params.cookies) |param| {
|
||||
try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param);
|
||||
}
|
||||
|
||||
try cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
const GetCookiesParam = struct { urls: ?[]const []const u8 = null };
|
||||
fn getCookies(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{};
|
||||
|
||||
// If not specified, use the URLs of the page and all of its subframes. TODO subframes
|
||||
const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL
|
||||
const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams};
|
||||
|
||||
var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len);
|
||||
for (param_urls) |url| {
|
||||
const uri = std.Uri.parse(url) catch return error.InvalidParams;
|
||||
|
||||
urls.appendAssumeCapacity(.{
|
||||
.host = try Cookie.parseDomain(cmd.arena, &uri, null),
|
||||
.path = try Cookie.parsePath(cmd.arena, &uri, null),
|
||||
.secure = std.mem.eql(u8, uri.scheme, "https"),
|
||||
});
|
||||
}
|
||||
|
||||
var jar = &bc.session.cookie_jar;
|
||||
jar.removeExpired(null);
|
||||
const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items, .urls = urls.items };
|
||||
try cmd.sendResult(.{ .cookies = writer }, .{});
|
||||
}
|
||||
|
||||
// Upsert a header into the headers array.
|
||||
// returns true if the header was added, false if it was updated
|
||||
fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool {
|
||||
for (headers.items) |*header| {
|
||||
if (std.mem.eql(u8, header.name, extra.name)) {
|
||||
header.value = extra.value;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
headers.appendAssumeCapacity(extra);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn httpRequestFail(arena: Allocator, bc: anytype, request: *const Notification.RequestFail) !void {
|
||||
// It's possible that the request failed because we aborted when the client
|
||||
// sent Target.closeTarget. In that case, bc.session_id will be cleared
|
||||
// already, and we can skip sending these messages to the client.
|
||||
const session_id = bc.session_id orelse return;
|
||||
|
||||
// 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);
|
||||
|
||||
// We're missing a bunch of fields, but, for now, this seems like enough
|
||||
try bc.cdp.sendEvent("Network.loadingFailed", .{
|
||||
.requestId = try std.fmt.allocPrint(arena, "REQ-{d}", .{request.id}),
|
||||
// Seems to be what chrome answers with. I assume it depends on the type of error?
|
||||
.type = "Ping",
|
||||
.errorText = request.err,
|
||||
.canceled = false,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Modify request with extra CDP headers
|
||||
try request.headers.ensureTotalCapacity(request.arena, request.headers.items.len + cdp.extra_headers.items.len);
|
||||
for (cdp.extra_headers.items) |extra| {
|
||||
const new = putAssumeCapacity(request.headers, extra);
|
||||
if (!new) log.debug(.cdp, "request header overwritten", .{ .name = extra.name });
|
||||
}
|
||||
|
||||
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.items.len);
|
||||
for (request.headers.items) |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}),
|
||||
.loaderId = bc.loader_id,
|
||||
.response = .{
|
||||
.url = url,
|
||||
.status = request.status,
|
||||
.headers = std.json.ArrayHashMap([]const u8){ .map = headers },
|
||||
},
|
||||
.frameId = target_id,
|
||||
}, .{ .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;
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.network setExtraHTTPHeaders" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
// _ = try ctx.loadBrowserContext(.{ .id = "NID-A", .session_id = "NESI-A" });
|
||||
try ctx.processMessage(.{ .id = 10, .method = "Target.createTarget", .params = .{ .url = "about/blank" } });
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Network.setExtraHTTPHeaders",
|
||||
.params = .{ .headers = .{ .foo = "bar" } },
|
||||
});
|
||||
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Network.setExtraHTTPHeaders",
|
||||
.params = .{ .headers = .{ .food = "bars" } },
|
||||
});
|
||||
|
||||
const bc = ctx.cdp().browser_context.?;
|
||||
try testing.expectEqual(bc.cdp.extra_headers.items.len, 1);
|
||||
|
||||
try ctx.processMessage(.{ .id = 5, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } });
|
||||
try testing.expectEqual(bc.cdp.extra_headers.items.len, 0);
|
||||
}
|
||||
|
||||
test "cdp.Network: cookies" {
|
||||
const ResCookie = CdpStorage.ResCookie;
|
||||
const CdpCookie = CdpStorage.CdpCookie;
|
||||
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
_ = try ctx.loadBrowserContext(.{ .id = "BID-S" });
|
||||
|
||||
// Initially empty
|
||||
try ctx.processMessage(.{
|
||||
.id = 3,
|
||||
.method = "Network.getCookies",
|
||||
.params = .{ .urls = &[_][]const u8{"https://example.com/pancakes"} },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 });
|
||||
|
||||
// Has cookies after setting them
|
||||
try ctx.processMessage(.{
|
||||
.id = 4,
|
||||
.method = "Network.setCookie",
|
||||
.params = CdpCookie{ .name = "test3", .value = "valuenot3", .url = "https://car.example.com/defnotpancakes" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 4 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 5,
|
||||
.method = "Network.setCookies",
|
||||
.params = .{
|
||||
.cookies = &[_]CdpCookie{
|
||||
.{ .name = "test3", .value = "value3", .url = "https://car.example.com/pan/cakes" },
|
||||
.{ .name = "test4", .value = "value4", .domain = "example.com", .path = "/mango" },
|
||||
},
|
||||
},
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 5 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 6,
|
||||
.method = "Network.getCookies",
|
||||
.params = .{ .urls = &[_][]const u8{"https://car.example.com/pan/cakes"} },
|
||||
});
|
||||
try ctx.expectSentResult(.{
|
||||
.cookies = &[_]ResCookie{
|
||||
.{ .name = "test3", .value = "value3", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes!
|
||||
},
|
||||
}, .{ .id = 6 });
|
||||
|
||||
// deleteCookies
|
||||
try ctx.processMessage(.{
|
||||
.id = 7,
|
||||
.method = "Network.deleteCookies",
|
||||
.params = .{ .name = "test3", .domain = "car.example.com" },
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 7 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 8,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
// Just the untouched test4 should be in the result
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{.{ .name = "test4", .value = "value4", .domain = ".example.com", .path = "/mango" }} }, .{ .id = 8 });
|
||||
|
||||
// Empty after clearBrowserCookies
|
||||
try ctx.processMessage(.{
|
||||
.id = 9,
|
||||
.method = "Network.clearBrowserCookies",
|
||||
});
|
||||
try ctx.expectSentResult(null, .{ .id = 9 });
|
||||
try ctx.processMessage(.{
|
||||
.id = 10,
|
||||
.method = "Storage.getCookies",
|
||||
.params = .{ .browserContextId = "BID-S" },
|
||||
});
|
||||
try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 10 });
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
// 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 URL = @import("../../url.zig").URL;
|
||||
const Page = @import("../../browser/page.zig").Page;
|
||||
const Notification = @import("../../notification.zig").Notification;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
getFrameTree,
|
||||
setLifecycleEventsEnabled,
|
||||
addScriptToEvaluateOnNewDocument,
|
||||
createIsolatedWorld,
|
||||
navigate,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
.getFrameTree => return getFrameTree(cmd),
|
||||
.setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
|
||||
.addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
|
||||
.createIsolatedWorld => return createIsolatedWorld(cmd),
|
||||
.navigate => return navigate(cmd),
|
||||
}
|
||||
}
|
||||
|
||||
const Frame = struct {
|
||||
id: []const u8,
|
||||
loaderId: []const u8,
|
||||
url: []const u8,
|
||||
domainAndRegistry: []const u8 = "",
|
||||
securityOrigin: []const u8,
|
||||
mimeType: []const u8 = "text/html",
|
||||
adFrameStatus: struct {
|
||||
adFrameType: []const u8 = "none",
|
||||
} = .{},
|
||||
secureContextType: []const u8,
|
||||
crossOriginIsolatedContextType: []const u8 = "NotIsolated",
|
||||
gatedAPIFeatures: [][]const u8 = &[0][]const u8{},
|
||||
};
|
||||
|
||||
fn getFrameTree(cmd: anytype) !void {
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
const target_id = bc.target_id orelse return error.TargetNotLoaded;
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.frameTree = .{
|
||||
.frame = Frame{
|
||||
.id = target_id,
|
||||
.loaderId = bc.loader_id,
|
||||
.securityOrigin = bc.security_origin,
|
||||
.url = bc.getURL() orelse "about:blank",
|
||||
.secureContextType = bc.secure_context_type,
|
||||
},
|
||||
},
|
||||
}, .{});
|
||||
}
|
||||
|
||||
fn setLifecycleEventsEnabled(cmd: anytype) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// enabled: bool,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
bc.page_life_cycle_events = true;
|
||||
return cmd.sendResult(null, .{});
|
||||
}
|
||||
|
||||
// TODO: hard coded method
|
||||
// With the command we receive a script we need to store and run for each new document.
|
||||
// Note that the worldName refers to the name given to the isolated world.
|
||||
fn addScriptToEvaluateOnNewDocument(cmd: anytype) !void {
|
||||
// const params = (try cmd.params(struct {
|
||||
// source: []const u8,
|
||||
// worldName: ?[]const u8 = null,
|
||||
// includeCommandLineAPI: bool = false,
|
||||
// runImmediately: bool = false,
|
||||
// })) orelse return error.InvalidParams;
|
||||
|
||||
return cmd.sendResult(.{
|
||||
.identifier = "1",
|
||||
}, .{});
|
||||
}
|
||||
|
||||
fn createIsolatedWorld(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
frameId: []const u8,
|
||||
worldName: []const u8,
|
||||
grantUniveralAccess: bool,
|
||||
})) orelse return error.InvalidParams;
|
||||
if (!params.grantUniveralAccess) {
|
||||
std.debug.print("grantUniveralAccess == false is not yet implemented", .{});
|
||||
// When grantUniveralAccess == false and the client attempts to resolve
|
||||
// or otherwise access a DOM or other JS Object from another context that should fail.
|
||||
}
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
try pageCreated(bc, page);
|
||||
const js_context = &world.executor.js_context.?;
|
||||
|
||||
// Create the auxdata json for the contextCreated event
|
||||
// Calling contextCreated will assign a Id to the context and send the contextCreated event
|
||||
const aux_data = try std.fmt.allocPrint(cmd.arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\"}}", .{params.frameId});
|
||||
bc.inspector.contextCreated(js_context, world.name, "", aux_data, false);
|
||||
|
||||
return cmd.sendResult(.{ .executionContextId = js_context.v8_context.debugContextId() }, .{});
|
||||
}
|
||||
|
||||
fn navigate(cmd: anytype) !void {
|
||||
const params = (try cmd.params(struct {
|
||||
url: []const u8,
|
||||
// referrer: ?[]const u8 = null,
|
||||
// transitionType: ?[]const u8 = null, // TODO: enum
|
||||
// frameId: ?[]const u8 = null,
|
||||
// referrerPolicy: ?[]const u8 = null, // TODO: enum
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
// didn't create?
|
||||
// const target_id = bc.target_id orelse return error.TargetIdNotLoaded;
|
||||
|
||||
// didn't attach?
|
||||
if (bc.session_id == null) {
|
||||
return error.SessionIdNotLoaded;
|
||||
}
|
||||
|
||||
const url = try URL.parse(params.url, "https");
|
||||
|
||||
var page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
bc.loader_id = bc.cdp.loader_id_gen.next();
|
||||
|
||||
try page.navigate(url, .{
|
||||
.reason = .address_bar,
|
||||
.cdp_id = cmd.input.id,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
var cdp = bc.cdp;
|
||||
|
||||
if (event.opts.reason != .address_bar) {
|
||||
bc.loader_id = bc.cdp.loader_id_gen.next();
|
||||
}
|
||||
|
||||
const loader_id = bc.loader_id;
|
||||
const target_id = bc.target_id orelse unreachable;
|
||||
const session_id = bc.session_id orelse unreachable;
|
||||
|
||||
bc.reset();
|
||||
|
||||
const reason_: ?[]const u8 = switch (event.opts.reason) {
|
||||
.anchor => "anchorClick",
|
||||
.script => "scriptInitiated",
|
||||
.form => switch (event.opts.method) {
|
||||
.GET => "formSubmissionGet",
|
||||
.POST => "formSubmissionPost",
|
||||
else => unreachable,
|
||||
},
|
||||
.address_bar => null,
|
||||
};
|
||||
if (reason_) |reason| {
|
||||
try cdp.sendEvent("Page.frameScheduledNavigation", .{
|
||||
.frameId = target_id,
|
||||
.delay = 0,
|
||||
.reason = reason,
|
||||
.url = event.url.raw,
|
||||
}, .{ .session_id = session_id });
|
||||
|
||||
try cdp.sendEvent("Page.frameRequestedNavigation", .{
|
||||
.frameId = target_id,
|
||||
.reason = reason,
|
||||
.url = event.url.raw,
|
||||
.disposition = "currentTab",
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
// frameStartedNavigating event
|
||||
try cdp.sendEvent("Page.frameStartedNavigating", .{
|
||||
.frameId = target_id,
|
||||
.url = event.url.raw,
|
||||
.loaderId = loader_id,
|
||||
.navigationType = "differentDocument",
|
||||
}, .{ .session_id = session_id });
|
||||
|
||||
// frameStartedLoading event
|
||||
try cdp.sendEvent("Page.frameStartedLoading", .{
|
||||
.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",
|
||||
.frameId = target_id,
|
||||
.loaderId = loader_id,
|
||||
.timestamp = event.timestamp,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
if (reason_ != null) {
|
||||
try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
|
||||
.frameId = target_id,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
// When we actually recreated the context we should have the inspector send this event, see: resetContextGroup
|
||||
// Sending this event will tell the client that the context ids they had are invalid and the context shouls be dropped
|
||||
// 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 });
|
||||
|
||||
{
|
||||
const page = bc.session.currentPage() orelse return error.PageNotLoaded;
|
||||
const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\"}}", .{target_id});
|
||||
bc.inspector.contextCreated(
|
||||
page.main_context,
|
||||
"",
|
||||
try page.origin(arena),
|
||||
aux_data,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (bc.isolated_world) |*isolated_world| {
|
||||
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.executor.js_context.?,
|
||||
isolated_world.name,
|
||||
"://",
|
||||
aux_json,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pageRemove(bc: anytype) !void {
|
||||
// The main page is going to be removed, we need to remove contexts from other worlds first.
|
||||
if (bc.isolated_world) |*isolated_world| {
|
||||
try isolated_world.removeContext();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pageCreated(bc: anytype, page: *Page) !void {
|
||||
if (bc.isolated_world) |*isolated_world| {
|
||||
// We need to recreate the isolated world context
|
||||
try isolated_world.createContext(page);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pageNavigated(bc: anytype, event: *const Notification.PageNavigated) !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);
|
||||
|
||||
var cdp = bc.cdp;
|
||||
const timestamp = event.timestamp;
|
||||
const loader_id = bc.loader_id;
|
||||
const target_id = bc.target_id orelse unreachable;
|
||||
const session_id = bc.session_id orelse unreachable;
|
||||
|
||||
// frameNavigated event
|
||||
try cdp.sendEvent("Page.frameNavigated", .{
|
||||
.type = "Navigation",
|
||||
.frame = Frame{
|
||||
.id = target_id,
|
||||
.url = event.url.raw,
|
||||
.loaderId = bc.loader_id,
|
||||
.securityOrigin = bc.security_origin,
|
||||
.secureContextType = bc.secure_context_type,
|
||||
},
|
||||
}, .{ .session_id = session_id });
|
||||
|
||||
// The DOM.documentUpdated event must be send after the frameNavigated one.
|
||||
// chromedp client expects to receive the events is this order.
|
||||
// see https://github.com/chromedp/chromedp/issues/1558
|
||||
try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
|
||||
|
||||
// domContentEventFired event
|
||||
// TODO: partially hard coded
|
||||
try cdp.sendEvent(
|
||||
"Page.domContentEventFired",
|
||||
.{ .timestamp = timestamp },
|
||||
.{ .session_id = session_id },
|
||||
);
|
||||
|
||||
// lifecycle DOMContentLoaded event
|
||||
// TODO: partially hard coded
|
||||
if (bc.page_life_cycle_events) {
|
||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||
.timestamp = timestamp,
|
||||
.name = "DOMContentLoaded",
|
||||
.frameId = target_id,
|
||||
.loaderId = loader_id,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
// loadEventFired event
|
||||
try cdp.sendEvent(
|
||||
"Page.loadEventFired",
|
||||
.{ .timestamp = timestamp },
|
||||
.{ .session_id = session_id },
|
||||
);
|
||||
|
||||
// lifecycle DOMContentLoaded event
|
||||
if (bc.page_life_cycle_events) {
|
||||
try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
|
||||
.timestamp = timestamp,
|
||||
.name = "load",
|
||||
.frameId = target_id,
|
||||
.loaderId = loader_id,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
// frameStoppedLoading
|
||||
return cdp.sendEvent("Page.frameStoppedLoading", .{
|
||||
.frameId = target_id,
|
||||
}, .{ .session_id = session_id });
|
||||
}
|
||||
|
||||
const LifecycleEvent = struct {
|
||||
frameId: []const u8,
|
||||
loaderId: ?[]const u8,
|
||||
name: []const u8,
|
||||
timestamp: u32,
|
||||
};
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "cdp.page: getFrameTree" {
|
||||
var ctx = testing.context();
|
||||
defer ctx.deinit();
|
||||
|
||||
{
|
||||
try testing.expectError(error.BrowserContextNotLoaded, ctx.processMessage(.{ .id = 10, .method = "Page.getFrameTree", .params = .{ .targetId = "X" } }));
|
||||
try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 10 });
|
||||
}
|
||||
|
||||
const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .target_id = "TID-3" });
|
||||
{
|
||||
try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
|
||||
try ctx.expectSentResult(.{
|
||||
.frameTree = .{
|
||||
.frame = .{
|
||||
.id = "TID-3",
|
||||
.loaderId = bc.loader_id,
|
||||
.url = "about:blank",
|
||||
.domainAndRegistry = "",
|
||||
.securityOrigin = bc.security_origin,
|
||||
.mimeType = "text/html",
|
||||
.adFrameStatus = .{
|
||||
.adFrameType = "none",
|
||||
},
|
||||
.secureContextType = bc.secure_context_type,
|
||||
.crossOriginIsolatedContextType = "NotIsolated",
|
||||
.gatedAPIFeatures = [_][]const u8{},
|
||||
},
|
||||
},
|
||||
}, .{ .id = 11 });
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// 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 fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.enable => return cmd.sendResult(null, .{}),
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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");
|
||||
|
||||
pub fn processMessage(cmd: anytype) !void {
|
||||
const action = std.meta.stringToEnum(enum {
|
||||
enable,
|
||||
runIfWaitingForDebugger,
|
||||
evaluate,
|
||||
addBinding,
|
||||
callFunctionOn,
|
||||
releaseObject,
|
||||
}, cmd.input.action) orelse return error.UnknownMethod;
|
||||
|
||||
switch (action) {
|
||||
.runIfWaitingForDebugger => return cmd.sendResult(null, .{}),
|
||||
else => return sendInspector(cmd, action),
|
||||
}
|
||||
}
|
||||
|
||||
fn sendInspector(cmd: anytype, action: anytype) !void {
|
||||
// save script in file at debug mode
|
||||
if (builtin.mode == .Debug) {
|
||||
try logInspector(cmd, action);
|
||||
}
|
||||
|
||||
const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;
|
||||
|
||||
// the result to return is handled directly by the inspector.
|
||||
bc.callInspector(cmd.input.json);
|
||||
}
|
||||
|
||||
fn logInspector(cmd: anytype, action: anytype) !void {
|
||||
const script = switch (action) {
|
||||
.evaluate => blk: {
|
||||
const params = (try cmd.params(struct {
|
||||
expression: []const u8,
|
||||
// contextId: ?u8 = null,
|
||||
// returnByValue: ?bool = null,
|
||||
// awaitPromise: ?bool = null,
|
||||
// userGesture: ?bool = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
break :blk params.expression;
|
||||
},
|
||||
.callFunctionOn => blk: {
|
||||
const params = (try cmd.params(struct {
|
||||
functionDeclaration: []const u8,
|
||||
// objectId: ?[]const u8 = null,
|
||||
// executionContextId: ?u8 = null,
|
||||
// arguments: ?[]struct {
|
||||
// value: ?[]const u8 = null,
|
||||
// objectId: ?[]const u8 = null,
|
||||
// } = null,
|
||||
// returnByValue: ?bool = null,
|
||||
// awaitPromise: ?bool = null,
|
||||
// userGesture: ?bool = null,
|
||||
})) orelse return error.InvalidParams;
|
||||
|
||||
break :blk params.functionDeclaration;
|
||||
},
|
||||
else => return,
|
||||
};
|
||||
const id = cmd.input.id orelse return error.RequiredId;
|
||||
const name = try std.fmt.allocPrint(cmd.arena, "id_{d}.js", .{id});
|
||||
|
||||
var dir = try std.fs.cwd().makeOpenPath(".zig-cache/tmp", .{});
|
||||
defer dir.close();
|
||||
|
||||
const f = try dir.createFile(name, .{});
|
||||
defer f.close();
|
||||
try f.writeAll(script);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user