mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-03-28 15:40:04 +00:00
Compare commits
3 Commits
docs/updat
...
pandasurf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec0d0b84c | ||
|
|
3544e98871 | ||
|
|
446e5b2ddd |
45
.github/actions/install/action.yml
vendored
45
.github/actions/install/action.yml
vendored
@@ -2,6 +2,10 @@ name: "Browsercore install"
|
||||
description: "Install deps for the project browsercore"
|
||||
|
||||
inputs:
|
||||
zig:
|
||||
description: 'Zig version to install'
|
||||
required: false
|
||||
default: '0.14.1'
|
||||
arch:
|
||||
description: 'CPU arch used to select the v8 lib'
|
||||
required: false
|
||||
@@ -13,19 +17,15 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.3.4'
|
||||
default: 'v0.1.24'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
default: '14.0.365.4'
|
||||
default: '13.6.233.8'
|
||||
cache-dir:
|
||||
description: 'cache dir to use'
|
||||
required: false
|
||||
default: '~/.cache'
|
||||
debug:
|
||||
description: 'enable v8 pre-built debug version, only available for linux x86_64'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -36,32 +36,45 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wget xz-utils ca-certificates clang make git
|
||||
sudo apt-get install -y wget xz-utils python3 ca-certificates git pkg-config libglib2.0-dev gperf libexpat1-dev cmake clang
|
||||
|
||||
# Zig version used from the `minimum_zig_version` field in build.zig.zon
|
||||
- uses: mlugg/setup-zig@v2
|
||||
|
||||
# Rust Toolchain for html5ever
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
version: ${{ inputs.zig }}
|
||||
|
||||
- name: Cache v8
|
||||
id: cache-v8
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-v8
|
||||
with:
|
||||
path: ${{ inputs.cache-dir }}/v8
|
||||
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}${{inputs.debug == 'true' && '_debug' || '' }}.a
|
||||
key: libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}_${{ inputs.zig-v8 }}.a
|
||||
|
||||
- if: ${{ steps.cache-v8.outputs.cache-hit != 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ${{ inputs.cache-dir }}/v8
|
||||
|
||||
wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}${{inputs.debug == 'true' && '_debug' || '' }}.a
|
||||
wget -O ${{ inputs.cache-dir }}/v8/libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${{ inputs.zig-v8 }}/libc_v8_${{ inputs.v8 }}_${{ inputs.os }}_${{ inputs.arch }}.a
|
||||
|
||||
- name: install v8
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p v8
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/libc_v8${{inputs.debug == 'true' && '_debug' || '' }}.a
|
||||
mkdir -p v8/out/${{ inputs.os }}/debug/obj/zig/
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/debug/obj/zig/libc_v8.a
|
||||
|
||||
mkdir -p v8/out/${{ inputs.os }}/release/obj/zig/
|
||||
ln -s ${{ inputs.cache-dir }}/v8/libc_v8.a v8/out/${{ inputs.os }}/release/obj/zig/libc_v8.a
|
||||
|
||||
- name: libiconv
|
||||
shell: bash
|
||||
run: make install-libiconv
|
||||
|
||||
- name: build mimalloc
|
||||
shell: bash
|
||||
run: make install-mimalloc
|
||||
|
||||
- name: build netsurf
|
||||
shell: bash
|
||||
run: make install-netsurf
|
||||
|
||||
@@ -6,13 +6,7 @@ env:
|
||||
AWS_BUCKET: ${{ vars.NIGHTLY_BUILD_AWS_BUCKET }}
|
||||
AWS_REGION: ${{ vars.NIGHTLY_BUILD_AWS_REGION }}
|
||||
|
||||
RELEASE: ${{ github.ref_type == 'tag' && github.ref_name || 'nightly' }}
|
||||
GIT_VERSION_FLAG: ${{ github.ref_type == 'tag' && format('-Dgit_version={0}', github.ref_name) || '' }}
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
schedule:
|
||||
- cron: "2 2 * * *"
|
||||
|
||||
@@ -29,23 +23,22 @@ jobs:
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
run: zig build --release=safe -Doptimize=ReleaseSafe -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: Rename binary
|
||||
run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
@@ -60,32 +53,30 @@ jobs:
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
tag: nightly
|
||||
|
||||
build-linux-aarch64:
|
||||
env:
|
||||
ARCH: aarch64
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-22.04-arm
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
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 }}
|
||||
@@ -100,34 +91,30 @@ jobs:
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
tag: nightly
|
||||
|
||||
build-macos-aarch64:
|
||||
env:
|
||||
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: 20
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
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 }}
|
||||
@@ -142,32 +129,30 @@ jobs:
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
tag: nightly
|
||||
|
||||
build-macos-x86_64:
|
||||
env:
|
||||
ARCH: x86_64
|
||||
OS: macos
|
||||
|
||||
runs-on: macos-14-large
|
||||
timeout-minutes: 20
|
||||
runs-on: macos-13
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dgit_commit=$(git rev-parse --short ${{ github.sha }}) ${{ env.GIT_VERSION_FLAG }}
|
||||
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 }}
|
||||
@@ -182,5 +167,4 @@ jobs:
|
||||
with:
|
||||
allowUpdates: true
|
||||
artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }}
|
||||
tag: ${{ env.RELEASE }}
|
||||
makeLatest: true
|
||||
tag: nightly
|
||||
66
.github/workflows/e2e-integration-test.yml
vendored
66
.github/workflows/e2e-integration-test.yml
vendored
@@ -1,66 +0,0 @@
|
||||
name: e2e-integration-test
|
||||
|
||||
env:
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "4 4 * * *"
|
||||
# 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@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/install
|
||||
|
||||
- name: zig build release
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
demo-scripts:
|
||||
name: demo-integration-scripts
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: run end to end integration tests
|
||||
run: |
|
||||
./lightpanda serve --log_level error & echo $! > LPD.pid
|
||||
go run integration/main.go
|
||||
kill `cat LPD.pid`
|
||||
297
.github/workflows/e2e-test.yml
vendored
297
.github/workflows/e2e-test.yml
vendored
@@ -9,13 +9,15 @@ env:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/zig-js-runtime"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
pull_request:
|
||||
|
||||
# By default GH trigger on types opened, synchronize and reopened.
|
||||
@@ -27,10 +29,12 @@ on:
|
||||
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -41,27 +45,76 @@ jobs:
|
||||
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@v6
|
||||
- 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 -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
run: zig build -Doptimize=ReleaseFast -Dcpu=x86_64 -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
puppeteer-perf:
|
||||
name: puppeteer-perf
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
MAX_MEMORY: 30000
|
||||
MAX_AVG_DURATION: 24
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
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 puppeteer
|
||||
run: |
|
||||
python3 -m http.server 1234 -d ./public & echo $! > PYTHON.pid
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
RUNS=100 npm run bench-puppeteer-cdp > puppeteer.out || exit 1
|
||||
cat /proc/`cat LPD.pid`/status |grep VmHWM|grep -oP '\d+' > LPD.VmHWM
|
||||
kill `cat LPD.pid` `cat PYTHON.pid`
|
||||
|
||||
- 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"
|
||||
|
||||
demo-scripts:
|
||||
name: demo-scripts
|
||||
needs: zig-build-release
|
||||
@@ -70,7 +123,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -78,7 +131,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -87,154 +140,22 @@ jobs:
|
||||
- name: run end to end tests
|
||||
run: |
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
go run runner/main.go
|
||||
go run runner/main.go --verbose
|
||||
kill `cat LPD.pid`
|
||||
|
||||
- name: build proxy
|
||||
run: |
|
||||
cd proxy
|
||||
go build
|
||||
|
||||
- name: run end to end tests through proxy
|
||||
run: |
|
||||
./proxy/proxy & echo $! > PROXY.id
|
||||
./lightpanda serve --http_proxy 'http://127.0.0.1:3000' & echo $! > LPD.pid
|
||||
go run runner/main.go
|
||||
kill `cat LPD.pid` `cat PROXY.id`
|
||||
|
||||
- name: run request interception through proxy
|
||||
run: |
|
||||
export PROXY_USERNAME=username PROXY_PASSWORD=password
|
||||
./proxy/proxy & echo $! > PROXY.id
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
|
||||
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
||||
kill `cat LPD.pid` `cat PROXY.id`
|
||||
|
||||
# e2e tests w/ web-bot-auth configuration on.
|
||||
wba-demo-scripts:
|
||||
name: wba-demo-scripts
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- run: echo "${{ secrets.WBA_PRIVATE_KEY_PEM }}" > private_key.pem
|
||||
|
||||
- name: run end to end tests
|
||||
run: |
|
||||
./lightpanda serve \
|
||||
--web_bot_auth_key_file private_key.pem \
|
||||
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
|
||||
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \
|
||||
& echo $! > LPD.pid
|
||||
go run runner/main.go
|
||||
kill `cat LPD.pid`
|
||||
|
||||
- name: build proxy
|
||||
run: |
|
||||
cd proxy
|
||||
go build
|
||||
|
||||
- name: run end to end tests through proxy
|
||||
run: |
|
||||
./proxy/proxy & echo $! > PROXY.id
|
||||
./lightpanda serve \
|
||||
--web_bot_auth_key_file private_key.pem \
|
||||
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
|
||||
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }} \
|
||||
--http_proxy 'http://127.0.0.1:3000' \
|
||||
& echo $! > LPD.pid
|
||||
go run runner/main.go
|
||||
kill `cat LPD.pid` `cat PROXY.id`
|
||||
|
||||
- name: run request interception through proxy
|
||||
run: |
|
||||
export PROXY_USERNAME=username PROXY_PASSWORD=password
|
||||
./proxy/proxy & echo $! > PROXY.id
|
||||
./lightpanda serve & echo $! > LPD.pid
|
||||
URL=https://demo-browser.lightpanda.io/campfire-commerce/ node puppeteer/proxy_auth.js
|
||||
BASE_URL=https://demo-browser.lightpanda.io/ node playwright/proxy_auth.js
|
||||
kill `cat LPD.pid` `cat PROXY.id`
|
||||
|
||||
wba-test:
|
||||
name: wba-test
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
# force a wakup of the auth server before requesting it w/ the test itself
|
||||
- run: curl https://${{ vars.WBA_DOMAIN }}
|
||||
|
||||
- name: run wba test
|
||||
shell: bash
|
||||
run: |
|
||||
node webbotauth/validator.js &
|
||||
VALIDATOR_PID=$!
|
||||
sleep 5
|
||||
|
||||
exec 3<<< "${{ secrets.WBA_PRIVATE_KEY_PEM }}"
|
||||
|
||||
./lightpanda fetch --dump http://127.0.0.1:8989/ \
|
||||
--web_bot_auth_key_file /proc/self/fd/3 \
|
||||
--web_bot_auth_keyid ${{ vars.WBA_KEY_ID }} \
|
||||
--web_bot_auth_domain ${{ vars.WBA_DOMAIN }}
|
||||
|
||||
wait $VALIDATOR_PID
|
||||
exec 3>&-
|
||||
|
||||
cdp-and-hyperfine-bench:
|
||||
name: cdp-and-hyperfine-bench
|
||||
needs: zig-build-release
|
||||
|
||||
env:
|
||||
MAX_VmHWM: 28000 # 28MB (KB)
|
||||
MAX_CG_PEAK: 8000 # 8MB (KB)
|
||||
MAX_AVG_DURATION: 17
|
||||
|
||||
# How to give cgroups access to the user actions-runner on the host:
|
||||
# $ sudo apt install cgroup-tools
|
||||
# $ sudo chmod o+w /sys/fs/cgroup/cgroup.procs
|
||||
# $ sudo mkdir -p /sys/fs/cgroup/actions-runner
|
||||
# $ sudo chown -R actions-runner:actions-runner /sys/fs/cgroup/actions-runner
|
||||
CG_ROOT: /sys/fs/cgroup
|
||||
CG: actions-runner/lpd_${{ github.run_id }}_${{ github.run_attempt }}
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-bench-hetzner
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
@@ -242,7 +163,7 @@ jobs:
|
||||
- run: npm install
|
||||
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
@@ -253,67 +174,23 @@ jobs:
|
||||
go run ws/main.go & echo $! > WS.pid
|
||||
sleep 2
|
||||
|
||||
- name: run lightpanda in cgroup
|
||||
run: |
|
||||
if [ ! -f /sys/fs/cgroup/cgroup.controllers ]; then
|
||||
echo "cgroup v2 not available: /sys/fs/cgroup/cgroup.controllers missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p $CG_ROOT/$CG
|
||||
cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.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`
|
||||
|
||||
PID=$(cat LPD.pid)
|
||||
while kill -0 $PID 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
if [ ! -f $CG_ROOT/$CG/memory.peak ]; then
|
||||
echo "memory.peak not available in $CG"
|
||||
exit 1
|
||||
fi
|
||||
cat $CG_ROOT/$CG/memory.peak > LPD.cg_mem_peak
|
||||
|
||||
- name: puppeteer result
|
||||
run: cat puppeteer.out
|
||||
|
||||
- name: cgroup memory regression
|
||||
run: |
|
||||
PEAK_BYTES=$(cat LPD.cg_mem_peak)
|
||||
PEAK_KB=$((PEAK_BYTES / 1024))
|
||||
echo "memory.peak_bytes=$PEAK_BYTES"
|
||||
echo "memory.peak_kb=$PEAK_KB"
|
||||
test "$PEAK_KB" -le "$MAX_CG_PEAK"
|
||||
|
||||
- name: virtual memory regression
|
||||
run: |
|
||||
export LPD_VmHWM=`cat LPD.VmHWM`
|
||||
echo "Peak resident set size: $LPD_VmHWM"
|
||||
test "$LPD_VmHWM" -le "$MAX_VmHWM"
|
||||
|
||||
- name: cleanup cgroup
|
||||
run: rmdir $CG_ROOT/$CG
|
||||
|
||||
- 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`
|
||||
export LPD_CG_PEAK_KB=$(( $(cat LPD.cg_mem_peak) / 1024 ))
|
||||
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM},\"cg_mem_peak\":${LPD_CG_PEAK_KB}}" > bench.json
|
||||
echo "{\"duration_total\":${TOTAL_DURATION},\"duration_avg\":${AVG_DURATION},\"mem_peak\":${LPD_VmHWM}}" > bench.json
|
||||
cat bench.json
|
||||
|
||||
- name: run hyperfine
|
||||
@@ -328,7 +205,7 @@ jobs:
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
path: |
|
||||
@@ -356,7 +233,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
|
||||
@@ -365,19 +242,3 @@ jobs:
|
||||
|
||||
- name: format and send json result
|
||||
run: /perf-fmt hyperfine ${{ github.sha }} hyperfine.json
|
||||
|
||||
browser-fetch:
|
||||
name: browser fetch
|
||||
needs: zig-build-release
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- run: ./lightpanda fetch https://demo-browser.lightpanda.io/campfire-commerce/
|
||||
|
||||
113
.github/workflows/wpt.yml
vendored
113
.github/workflows/wpt.yml
vendored
@@ -5,126 +5,39 @@ env:
|
||||
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 }}
|
||||
AWS_CF_DISTRIBUTION: ${{ vars.AWS_CF_DISTRIBUTION }}
|
||||
LIGHTPANDA_DISABLE_TELEMETRY: true
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "21 2 * * *"
|
||||
- cron: "23 2 * * *"
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
wpt-build-release:
|
||||
name: zig build release
|
||||
wpt:
|
||||
name: web platform tests json output
|
||||
|
||||
env:
|
||||
ARCH: aarch64
|
||||
OS: linux
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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: v8 snapshot
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin
|
||||
|
||||
- name: zig build release
|
||||
run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic -Dgit_commit=$(git rev-parse --short ${{ github.sha }})
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
path: |
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
wpt-build-runner:
|
||||
name: build wpt runner
|
||||
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: 'lightpanda-io/demo'
|
||||
fetch-depth: 0
|
||||
|
||||
- run: |
|
||||
cd ./wptrunner
|
||||
CGO_ENABLED=0 go build
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: wptrunner
|
||||
path: |
|
||||
wptrunner/wptrunner
|
||||
retention-days: 1
|
||||
|
||||
run-wpt:
|
||||
name: web platform tests json output
|
||||
needs:
|
||||
- wpt-build-release
|
||||
- wpt-build-runner
|
||||
|
||||
# use a self host runner.
|
||||
runs-on: lpd-wpt-aws
|
||||
timeout-minutes: 600
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: fork
|
||||
repository: 'lightpanda-io/wpt'
|
||||
fetch-depth: 0
|
||||
|
||||
# The hosts are configured manually on the self host runner.
|
||||
# - name: create custom hosts
|
||||
# run: ./wpt make-hosts-file | sudo tee -a /etc/hosts
|
||||
|
||||
- name: generate manifest
|
||||
run: ./wpt manifest
|
||||
|
||||
- name: download lightpanda release
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: lightpanda-build-release
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- name: download wptrunner
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: wptrunner
|
||||
|
||||
- run: chmod a+x ./wptrunner
|
||||
|
||||
- name: run test with json output
|
||||
run: |
|
||||
./wpt serve 2> /dev/null & echo $! > WPT.pid
|
||||
sleep 20s
|
||||
./wptrunner -lpd-path ./lightpanda -json -concurrency 5 -pool 5 --mem-limit 400 > wpt.json
|
||||
kill `cat WPT.pid`
|
||||
- name: json output
|
||||
run: zig build wpt -- --json > wpt.json
|
||||
|
||||
- name: write commit
|
||||
run: |
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wpt-results
|
||||
path: |
|
||||
@@ -134,7 +47,7 @@ jobs:
|
||||
|
||||
perf-fmt:
|
||||
name: perf-fmt
|
||||
needs: run-wpt
|
||||
needs: wpt
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@@ -147,7 +60,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: wpt-results
|
||||
|
||||
|
||||
63
.github/workflows/zig-fmt.yml
vendored
Normal file
63
.github/workflows/zig-fmt.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: zig-fmt
|
||||
|
||||
env:
|
||||
ZIG_VERSION: 0.14.1
|
||||
|
||||
on:
|
||||
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"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
zig-fmt:
|
||||
name: zig fmt
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
version: ${{ env.ZIG_VERSION }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run zig fmt
|
||||
id: fmt
|
||||
run: |
|
||||
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -s zig-fmt.err ]; then
|
||||
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [ -s zig-fmt.err2 ]; then
|
||||
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Fail the job
|
||||
if: steps.fmt.outputs.zig_fmt_errs != ''
|
||||
run: exit 1
|
||||
124
.github/workflows/zig-test.yml
vendored
124
.github/workflows/zig-test.yml
vendored
@@ -5,18 +5,20 @@ env:
|
||||
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]
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
- "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
|
||||
@@ -26,95 +28,90 @@ on:
|
||||
|
||||
paths:
|
||||
- ".github/**"
|
||||
- "src/**"
|
||||
- "build.zig"
|
||||
- "build.zig.zon"
|
||||
|
||||
- "src/**/*.zig"
|
||||
- "src/*.zig"
|
||||
- "vendor/**"
|
||||
- ".github/**"
|
||||
- "vendor/**"
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
zig-fmt:
|
||||
name: zig fmt
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
zig-build-dev:
|
||||
name: zig build dev
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Zig version used from the `minimum_zig_version` field in build.zig.zon
|
||||
- uses: mlugg/setup-zig@v2
|
||||
|
||||
- name: Run zig fmt
|
||||
id: fmt
|
||||
run: |
|
||||
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
|
||||
delimiter="$(openssl rand -hex 8)"
|
||||
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
if [ -s zig-fmt.err ]; then
|
||||
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
if [ -s zig-fmt.err2 ]; then
|
||||
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
|
||||
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
|
||||
fi
|
||||
|
||||
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Fail the job
|
||||
if: steps.fmt.outputs.zig_fmt_errs != ''
|
||||
run: exit 1
|
||||
|
||||
zig-test-debug:
|
||||
name: zig test using v8 in debug mode
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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 debug
|
||||
run: zig build
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
debug: true
|
||||
name: lightpanda-build-dev
|
||||
path: |
|
||||
zig-out/bin/lightpanda
|
||||
retention-days: 1
|
||||
|
||||
- name: zig build test
|
||||
run: zig build -Dprebuilt_v8_path=v8/libc_v8_debug.a -Dtsan=true test
|
||||
browser-fetch:
|
||||
name: browser fetch
|
||||
needs: zig-build-dev
|
||||
|
||||
zig-test-release:
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightpanda-build-dev
|
||||
|
||||
- run: chmod a+x ./lightpanda
|
||||
|
||||
- run: ./lightpanda fetch https://httpbin.io/xhr/get
|
||||
|
||||
zig-test:
|
||||
name: zig test
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
# Don't run the CI with draft PR.
|
||||
if: github.event.pull_request.draft == false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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 test
|
||||
run: METRICS=true zig build -Dprebuilt_v8_path=v8/libc_v8.a test > bench.json
|
||||
run: zig build test -- --json > bench.json
|
||||
|
||||
- name: write commit
|
||||
run: |
|
||||
echo "${{github.sha}}" > commit.txt
|
||||
|
||||
- name: upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
path: |
|
||||
@@ -124,13 +121,14 @@ jobs:
|
||||
|
||||
bench-fmt:
|
||||
name: perf-fmt
|
||||
needs: zig-test-release
|
||||
needs: zig-test
|
||||
|
||||
# Don't execute on PR
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
container:
|
||||
image: ghcr.io/lightpanda-io/perf-fmt:latest
|
||||
credentials:
|
||||
@@ -139,7 +137,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: download artifact
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-results
|
||||
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
zig-cache
|
||||
/.zig-cache/
|
||||
/.lp-cache/
|
||||
zig-out
|
||||
/vendor/netsurf/out
|
||||
/vendor/libiconv/
|
||||
lightpanda.id
|
||||
/src/html5ever/target/
|
||||
src/snapshot.bin
|
||||
/v8/
|
||||
|
||||
21
.gitmodules
vendored
Normal file
21
.gitmodules
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
[submodule "vendor/netsurf/libwapcaplet"]
|
||||
path = vendor/netsurf/libwapcaplet
|
||||
url = https://github.com/lightpanda-io/libwapcaplet.git/
|
||||
[submodule "vendor/netsurf/libparserutils"]
|
||||
path = vendor/netsurf/libparserutils
|
||||
url = https://github.com/lightpanda-io/libparserutils.git/
|
||||
[submodule "vendor/netsurf/libdom"]
|
||||
path = vendor/netsurf/libdom
|
||||
url = https://github.com/lightpanda-io/libdom.git/
|
||||
[submodule "vendor/netsurf/share/netsurf-buildsystem"]
|
||||
path = vendor/netsurf/share/netsurf-buildsystem
|
||||
url = https://source.netsurf-browser.org/buildsystem.git
|
||||
[submodule "vendor/netsurf/libhubbub"]
|
||||
path = vendor/netsurf/libhubbub
|
||||
url = https://github.com/lightpanda-io/libhubbub.git/
|
||||
[submodule "tests/wpt"]
|
||||
path = tests/wpt
|
||||
url = https://github.com/lightpanda-io/wpt
|
||||
[submodule "vendor/mimalloc"]
|
||||
path = vendor/mimalloc
|
||||
url = https://github.com/microsoft/mimalloc.git/
|
||||
101
Dockerfile
101
Dockerfile
@@ -1,78 +1,75 @@
|
||||
FROM debian:stable-slim
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG=0.14.1
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.0.365.4
|
||||
ARG ZIG_V8=v0.3.4
|
||||
ARG TARGETPLATFORM
|
||||
ARG ARCH=x86_64
|
||||
ARG V8=13.6.233.8
|
||||
ARG ZIG_V8=v0.1.24
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
apt-get install -yq xz-utils ca-certificates \
|
||||
apt-get install -yq xz-utils \
|
||||
python3 ca-certificates git \
|
||||
pkg-config libglib2.0-dev \
|
||||
clang make curl git
|
||||
|
||||
# Get Rust
|
||||
RUN curl https://sh.rustup.rs -sSf | sh -s -- --profile minimal -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
gperf libexpat1-dev \
|
||||
cmake clang \
|
||||
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 -C /
|
||||
|
||||
# clone lightpanda
|
||||
RUN git clone https://github.com/lightpanda-io/browser.git
|
||||
WORKDIR /browser
|
||||
tar xvzf minisign-${MINISIG}-linux.tar.gz
|
||||
|
||||
# install zig
|
||||
RUN ZIG=$(grep '\.minimum_zig_version = "' "build.zig.zon" | cut -d'"' -f2) && \
|
||||
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 && \
|
||||
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz
|
||||
RUN curl --fail -L -O https://ziglang.org/download/${ZIG}/zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
|
||||
|
||||
RUN minisign-linux/${ARCH}/minisign -Vm zig-${ARCH}-linux-${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-${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
|
||||
|
||||
# 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/ && \
|
||||
mv libc_v8.a v8/libc_v8.a
|
||||
# clean up zig install
|
||||
RUN rm -fr zig-${ARCH}-linux-${ZIG}.tar.xz zig-${ARCH}-linux-${ZIG}.tar.xz.minisig
|
||||
|
||||
# build v8 snapshot
|
||||
RUN zig build -Doptimize=ReleaseFast \
|
||||
-Dprebuilt_v8_path=v8/libc_v8.a \
|
||||
snapshot_creator -- src/snapshot.bin
|
||||
# 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 git@github.com:lightpanda-io/browser.git
|
||||
|
||||
WORKDIR /browser
|
||||
|
||||
# install deps
|
||||
RUN git submodule init && \
|
||||
git submodule update --recursive
|
||||
|
||||
RUN make install-libiconv && \
|
||||
make install-netsurf && \
|
||||
make install-mimalloc
|
||||
|
||||
# download and install v8
|
||||
RUN curl --fail -L -o libc_v8.a https://github.com/lightpanda-io/zig-v8-fork/releases/download/${ZIG_V8}/libc_v8_${V8}_linux_${ARCH}.a && \
|
||||
mkdir -p v8/out/linux/release/obj/zig/ && \
|
||||
mv libc_v8.a v8/out/linux/release/obj/zig/libc_v8.a
|
||||
|
||||
# build release
|
||||
RUN zig build -Doptimize=ReleaseFast \
|
||||
-Dsnapshot_path=../../snapshot.bin \
|
||||
-Dprebuilt_v8_path=v8/libc_v8.a \
|
||||
-Dgit_commit=$(git rev-parse --short HEAD)
|
||||
RUN make build
|
||||
|
||||
FROM debian:stable-slim
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
apt-get install -yq tini
|
||||
|
||||
FROM debian:stable-slim
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# copy ca certificates
|
||||
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY --from=0 /browser/zig-out/bin/lightpanda /bin/lightpanda
|
||||
COPY --from=1 /usr/bin/tini /usr/bin/tini
|
||||
|
||||
EXPOSE 9222/tcp
|
||||
|
||||
# Lightpanda install only some signal handlers, and PID 1 doesn't have a default SIGTERM signal handler.
|
||||
# Using "tini" as PID1 ensures that signals work as expected, so e.g. "docker stop" will not hang.
|
||||
# (See https://github.com/krallin/tini#why-tini).
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222", "--log_level", "info"]
|
||||
CMD ["/bin/lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"]
|
||||
|
||||
@@ -5,6 +5,14 @@ List](https://spdx.org/licenses/).
|
||||
|
||||
The default license for this project is [AGPL-3.0-only](LICENSE).
|
||||
|
||||
## MIT
|
||||
|
||||
The following files are licensed under MIT:
|
||||
|
||||
```
|
||||
src/polyfill/fetch.js
|
||||
```
|
||||
|
||||
The following directories and their subdirectories are licensed under their
|
||||
original upstream licenses:
|
||||
|
||||
|
||||
203
Makefile
203
Makefile
@@ -34,7 +34,7 @@ endif
|
||||
|
||||
## Display this help screen
|
||||
help:
|
||||
@printf "\033[36m%-35s %s\033[0m\n" "Command" "Usage"
|
||||
@printf "\e[36m%-35s %s\e[0m\n" "Command" "Usage"
|
||||
@sed -n -e '/^## /{'\
|
||||
-e 's/## //g;'\
|
||||
-e 'h;'\
|
||||
@@ -47,57 +47,200 @@ help:
|
||||
|
||||
# $(ZIG) commands
|
||||
# ------------
|
||||
.PHONY: build build-v8-snapshot build-dev run run-release test bench data end2end
|
||||
.PHONY: build build-dev run run-release shell test bench download-zig wpt data get-v8 build-v8 build-v8-dev
|
||||
.PHONY: end2end
|
||||
|
||||
## Build v8 snapshot
|
||||
build-v8-snapshot:
|
||||
@printf "\033[36mBuilding v8 snapshot (release safe)...\033[0m\n"
|
||||
@$(ZIG) build -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\033[33mBuild OK\033[0m\n"
|
||||
zig_version = $(shell grep 'recommended_zig_version = "' "vendor/zig-js-runtime/build.zig" | cut -d'"' -f2)
|
||||
|
||||
## Build in release-fast mode
|
||||
build: build-v8-snapshot
|
||||
@printf "\033[36mBuilding (release fast)...\033[0m\n"
|
||||
@$(ZIG) build -Doptimize=ReleaseFast -Dsnapshot_path=../../snapshot.bin -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\033[33mBuild OK\033[0m\n"
|
||||
## Download the zig recommended version
|
||||
download-zig:
|
||||
$(eval url = "https://ziglang.org/download/$(zig_version)/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
|
||||
$(eval dest = "/tmp/zig-$(OS)-$(ARCH)-$(zig_version).tar.xz")
|
||||
@printf "\e[36mDownload zig version $(zig_version)...\e[0m\n"
|
||||
@curl -o "$(dest)" -L "$(url)" || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
@printf "\e[33mDownloaded $(dest)\e[0m\n"
|
||||
|
||||
## 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;)
|
||||
@printf "\e[33mBuild OK\e[0m\n"
|
||||
|
||||
## Build in debug mode
|
||||
build-dev:
|
||||
@printf "\033[36mBuilding (debug)...\033[0m\n"
|
||||
@$(ZIG) build -Dgit_commit=$$(git rev-parse --short HEAD) || (printf "\033[33mBuild ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\033[33mBuild OK\033[0m\n"
|
||||
@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;)
|
||||
@printf "\e[33mBuild OK\e[0m\n"
|
||||
|
||||
## Run the server in release mode
|
||||
run: build
|
||||
@printf "\033[36mRunning...\033[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\e[36mRunning...\e[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Run the server in debug mode
|
||||
run-debug: build-dev
|
||||
@printf "\033[36mRunning...\033[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\033[33mRun ERROR\033[0m\n"; exit 1;)
|
||||
@printf "\e[36mRunning...\e[0m\n"
|
||||
@./zig-out/bin/lightpanda || (printf "\e[33mRun ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Test - `grep` is used to filter out the huge compile command on build
|
||||
ifeq ($(OS), macos)
|
||||
## 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;)
|
||||
|
||||
## 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;)
|
||||
|
||||
wpt-summary:
|
||||
@printf "\e[36mBuilding wpt...\e[0m\n"
|
||||
@$(ZIG) build wpt -- --summary $(filter-out $@,$(MAKECMDGOALS)) || (printf "\e[33mBuild ERROR\e[0m\n"; exit 1;)
|
||||
|
||||
## Test
|
||||
test:
|
||||
@script -q /dev/null sh -c 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' 2>&1 \
|
||||
| grep --line-buffered -v "^/.*zig test -freference-trace"
|
||||
else
|
||||
test:
|
||||
@script -qec 'TEST_FILTER="${F}" $(ZIG) build test -freference-trace' /dev/null 2>&1 \
|
||||
| grep --line-buffered -v "^/.*zig test -freference-trace"
|
||||
endif
|
||||
@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
|
||||
|
||||
# Install and build required dependencies commands
|
||||
# ------------
|
||||
.PHONY: install
|
||||
.PHONY: install-submodule
|
||||
.PHONY: install-libiconv
|
||||
.PHONY: _install-netsurf install-netsurf clean-netsurf test-netsurf install-netsurf-dev
|
||||
.PHONY: install-mimalloc install-mimalloc-dev clean-mimalloc
|
||||
.PHONY: install-dev install
|
||||
|
||||
install: build
|
||||
## Install and build dependencies for release
|
||||
install: install-submodule 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-netsurf-dev: _install-netsurf
|
||||
install-netsurf-dev: OPTCFLAGS := -O0 -g -DNDEBUG
|
||||
|
||||
install-netsurf: _install-netsurf
|
||||
install-netsurf: OPTCFLAGS := -DNDEBUG
|
||||
|
||||
BC_NS := $(BC)vendor/netsurf/out/$(OS)-$(ARCH)
|
||||
ICONV := $(BC)vendor/libiconv/out/$(OS)-$(ARCH)
|
||||
# TODO: add Linux iconv path (I guess it depends on the distro)
|
||||
# TODO: this way of linking libiconv is not ideal. We should have a more generic way
|
||||
# and stick to a specif version. Maybe build from source. Anyway not now.
|
||||
_install-netsurf: clean-netsurf
|
||||
@printf "\e[36mInstalling NetSurf...\e[0m\n" && \
|
||||
ls $(ICONV)/lib/libiconv.a 1> /dev/null || (printf "\e[33mERROR: you need to execute 'make install-libiconv'\e[0m\n"; exit 1;) && \
|
||||
mkdir -p $(BC_NS) && \
|
||||
cp -R vendor/netsurf/share $(BC_NS) && \
|
||||
export PREFIX=$(BC_NS) && \
|
||||
export OPTLDFLAGS="-L$(ICONV)/lib" && \
|
||||
export OPTCFLAGS="$(OPTCFLAGS) -I$(ICONV)/include" && \
|
||||
printf "\e[33mInstalling libwapcaplet...\e[0m\n" && \
|
||||
cd vendor/netsurf/libwapcaplet && \
|
||||
BUILDDIR=$(BC_NS)/build/libwapcaplet make install && \
|
||||
cd ../libparserutils && \
|
||||
printf "\e[33mInstalling libparserutils...\e[0m\n" && \
|
||||
BUILDDIR=$(BC_NS)/build/libparserutils make install && \
|
||||
cd ../libhubbub && \
|
||||
printf "\e[33mInstalling libhubbub...\e[0m\n" && \
|
||||
BUILDDIR=$(BC_NS)/build/libhubbub make install && \
|
||||
rm src/treebuilder/autogenerated-element-type.c && \
|
||||
cd ../libdom && \
|
||||
printf "\e[33mInstalling libdom...\e[0m\n" && \
|
||||
BUILDDIR=$(BC_NS)/build/libdom make install && \
|
||||
printf "\e[33mRunning libdom example...\e[0m\n" && \
|
||||
cd examples && \
|
||||
$(ZIG) cc \
|
||||
-I$(ICONV)/include \
|
||||
-I$(BC_NS)/include \
|
||||
-L$(ICONV)/lib \
|
||||
-L$(BC_NS)/lib \
|
||||
-liconv \
|
||||
-ldom \
|
||||
-lhubbub \
|
||||
-lparserutils \
|
||||
-lwapcaplet \
|
||||
-o a.out \
|
||||
dom-structure-dump.c \
|
||||
$(ICONV)/lib/libiconv.a && \
|
||||
./a.out > /dev/null && \
|
||||
rm a.out && \
|
||||
printf "\e[36mDone NetSurf $(OS)\e[0m\n"
|
||||
|
||||
clean-netsurf:
|
||||
@printf "\e[36mCleaning NetSurf build...\e[0m\n" && \
|
||||
rm -Rf $(BC_NS)
|
||||
|
||||
test-netsurf:
|
||||
@printf "\e[36mTesting NetSurf...\e[0m\n" && \
|
||||
export PREFIX=$(BC_NS) && \
|
||||
export LDFLAGS="-L$(ICONV)/lib -L$(BC_NS)/lib" && \
|
||||
export CFLAGS="-I$(ICONV)/include -I$(BC_NS)/include" && \
|
||||
cd vendor/netsurf/libdom && \
|
||||
BUILDDIR=$(BC_NS)/build/libdom make test
|
||||
|
||||
download-libiconv:
|
||||
ifeq ("$(wildcard vendor/libiconv/libiconv-1.17)","")
|
||||
@mkdir -p vendor/libiconv
|
||||
@cd vendor/libiconv && \
|
||||
curl https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz | tar -xvzf -
|
||||
endif
|
||||
|
||||
install-libiconv: download-libiconv clean-libiconv
|
||||
@cd vendor/libiconv/libiconv-1.17 && \
|
||||
./configure --prefix=$(ICONV) --enable-static && \
|
||||
make && make install
|
||||
|
||||
clean-libiconv:
|
||||
ifneq ("$(wildcard vendor/libiconv/libiconv-1.17/Makefile)","")
|
||||
@cd vendor/libiconv/libiconv-1.17 && \
|
||||
make clean
|
||||
endif
|
||||
|
||||
data:
|
||||
cd src/data && go run public_suffix_list_gen.go > public_suffix_list.zig
|
||||
|
||||
.PHONY: _build_mimalloc
|
||||
|
||||
MIMALLOC := $(BC)vendor/mimalloc/out/$(OS)-$(ARCH)
|
||||
_build_mimalloc: clean-mimalloc
|
||||
@mkdir -p $(MIMALLOC)/build && \
|
||||
cd $(MIMALLOC)/build && \
|
||||
cmake -DMI_BUILD_SHARED=OFF -DMI_BUILD_OBJECT=OFF -DMI_BUILD_TESTS=OFF -DMI_OVERRIDE=OFF $(OPTS) ../../.. && \
|
||||
make && \
|
||||
mkdir -p $(MIMALLOC)/lib
|
||||
|
||||
install-mimalloc-dev: _build_mimalloc
|
||||
install-mimalloc-dev: OPTS=-DCMAKE_BUILD_TYPE=Debug
|
||||
install-mimalloc-dev:
|
||||
@cd $(MIMALLOC) && \
|
||||
mv build/libmimalloc-debug.a lib/libmimalloc.a
|
||||
|
||||
install-mimalloc: _build_mimalloc
|
||||
install-mimalloc:
|
||||
@cd $(MIMALLOC) && \
|
||||
mv build/libmimalloc.a lib/libmimalloc.a
|
||||
|
||||
clean-mimalloc:
|
||||
@rm -Rf $(MIMALLOC)/build
|
||||
|
||||
## Init and update git submodule
|
||||
install-submodule:
|
||||
@git submodule init && \
|
||||
git submodule update
|
||||
|
||||
468
README.md
468
README.md
@@ -1,22 +1,30 @@
|
||||
<p align="center">
|
||||
<a href="https://lightpanda.io"><img src="https://cdn.lightpanda.io/assets/images/logo/lpd-logo.png" alt="Logo" height=170></a>
|
||||
</p>
|
||||
<h1 align="center">Lightpanda Browser</h1>
|
||||
<p align="center">
|
||||
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
|
||||
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<h1 align="center">Lightpanda Browser</h1>
|
||||
|
||||
<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)
|
||||
[](https://discord.gg/K63XeymfB5)
|
||||
|
||||
</div>
|
||||
<div align="center">
|
||||
|
||||
Lightpanda is the open-source browser made for headless usage:
|
||||
|
||||
- Javascript execution
|
||||
- Support of Web APIs (partial, WIP)
|
||||
- Compatible with Playwright[^1], Puppeteer through CDP (WIP)
|
||||
|
||||
Fast web automation for AI agents, LLM training, scraping and testing:
|
||||
|
||||
- Ultra-low memory footprint (9x less than Chrome)
|
||||
- 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)
|
||||
@@ -25,42 +33,15 @@ Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
|
||||
](https://github.com/lightpanda-io/demo)
|
||||
</div>
|
||||
|
||||
## Table of Contents
|
||||
_Puppeteer requesting 100 pages from a local website on a AWS EC2 m5.large instance.
|
||||
See [benchmark details](https://github.com/lightpanda-io/demo)._
|
||||
|
||||
- [Benchmarks](#benchmarks)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Install](#install)
|
||||
- [Dump a URL](#dump-a-url)
|
||||
- [Start a CDP Server](#start-a-cdp-server)
|
||||
- [Telemetry](#telemetry)
|
||||
- [Lightpanda vs Headless Chrome](#lightpanda-vs-headless-chrome)
|
||||
- [What Lightpanda supports today](#what-lightpanda-supports-today)
|
||||
- [Use Cases](#use-cases)
|
||||
- [Architecture](#architecture)
|
||||
- [Why Lightpanda?](#why-lightpanda)
|
||||
- [Build from Source](#build-from-source)
|
||||
- [Test](#test)
|
||||
- [Contributing](#contributing)
|
||||
- [Compatibility Note](#compatibility-note)
|
||||
- [FAQ](#faq)
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
_Puppeteer requesting 100 pages from a local website on an AWS EC2 m5.large instance. See [benchmark details](https://github.com/lightpanda-io/demo)._
|
||||
|
||||
| Metric | Lightpanda | Headless Chrome | Difference |
|
||||
| :---- | :---- | :---- | :---- |
|
||||
| Memory (peak, 100 pages) | 24 MB | 207 MB | ~9x less |
|
||||
| Execution time (100 pages) | 2.3s | 25.2s | ~11x faster |
|
||||
|
||||
---
|
||||
[^1]: **Playwright support disclaimer:**
|
||||
Due to the nature of Playwright, a script that works with the current version of the browser may not function correctly with a future version. Playwright uses an intermediate JavaScript layer that selects an execution strategy based on the browser's available features. If Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may choose to execute different code for the same script. This new code path could attempt to use features that are not yet implemented. Lightpanda makes an effort to add compatibility tests, but we can't cover all scenarios. If you encounter an issue, please create a [GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last known working version of the script.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Install
|
||||
**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
|
||||
@@ -83,82 +64,31 @@ chmod a+x ./lightpanda
|
||||
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`.
|
||||
```console
|
||||
docker run -d --name lightpanda -p 9222:9222 lightpanda/browser:nightly
|
||||
```
|
||||
|
||||
### Dump a URL
|
||||
|
||||
```console
|
||||
./lightpanda fetch --obey_robots --log_format pretty --log_level info https://demo-browser.lightpanda.io/campfire-commerce/
|
||||
./lightpanda fetch --dump https://lightpanda.io
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Example output</summary>
|
||||
|
||||
```console
|
||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||
disabled = false
|
||||
|
||||
INFO page : navigate . . . . . . . . . . . . . . . . . . . . [+6ms]
|
||||
url = https://demo-browser.lightpanda.io/campfire-commerce/
|
||||
method = GET
|
||||
reason = address_bar
|
||||
body = false
|
||||
req_id = 1
|
||||
|
||||
INFO browser : executing script . . . . . . . . . . . . . . [+118ms]
|
||||
src = https://demo-browser.lightpanda.io/campfire-commerce/script.js
|
||||
kind = javascript
|
||||
cacheable = true
|
||||
|
||||
INFO http : request complete . . . . . . . . . . . . . . . . [+140ms]
|
||||
source = xhr
|
||||
url = https://demo-browser.lightpanda.io/campfire-commerce/json/product.json
|
||||
status = 200
|
||||
len = 4770
|
||||
|
||||
INFO http : request complete . . . . . . . . . . . . . . . . [+141ms]
|
||||
source = fetch
|
||||
url = https://demo-browser.lightpanda.io/campfire-commerce/json/reviews.json
|
||||
status = 200
|
||||
len = 1615
|
||||
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')
|
||||
<!DOCTYPE html>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Start a CDP server
|
||||
|
||||
```console
|
||||
./lightpanda serve --obey_robots --log_format pretty --log_level info --host 127.0.0.1 --port 9222
|
||||
./lightpanda serve --host 127.0.0.1 --port 9222
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Example output</summary>
|
||||
|
||||
```console
|
||||
INFO telemetry : telemetry status . . . . . . . . . . . . . [+0ms]
|
||||
disabled = false
|
||||
|
||||
INFO app : server running . . . . . . . . . . . . . . . . . [+0ms]
|
||||
address = 127.0.0.1:9222
|
||||
info(websocket): starting blocking worker to listen on 127.0.0.1:9222
|
||||
info(server): accepting new conn...
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Once the CDP server started, you can run a Puppeteer script by configuring the
|
||||
`browserWSEndpoint`.
|
||||
|
||||
<details>
|
||||
<summary>Example Puppeteer script</summary>
|
||||
|
||||
```js
|
||||
'use strict'
|
||||
|
||||
@@ -174,7 +104,7 @@ const context = await browser.createBrowserContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Dump all the links from the page.
|
||||
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
|
||||
await page.goto('https://wikipedia.com/');
|
||||
|
||||
const links = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('a')).map(row => {
|
||||
@@ -189,176 +119,147 @@ await context.close();
|
||||
await browser.disconnect();
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### 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).
|
||||
|
||||
## Lightpanda vs Headless Chrome
|
||||
## Status
|
||||
|
||||
Lightpanda is not a general-purpose browser. It is built specifically for headless workloads.
|
||||
Lightpanda is still a work in progress and is currently at a Beta stage.
|
||||
|
||||
**Use Lightpanda when you need:**
|
||||
:warning: You should expect most websites to fail or crash.
|
||||
|
||||
- Low-memory scraping or data extraction at scale
|
||||
- AI agent browsing (via MCP or CDP)
|
||||
- Fast CI test runs against a headless browser
|
||||
- Markdown/text extraction from JS-rendered pages
|
||||
- Minimal footprint: single binary, no Chromium install
|
||||
Here are the key features we have implemented:
|
||||
|
||||
**Use Headless Chrome when you need:**
|
||||
- [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
|
||||
|
||||
- Full visual rendering, screenshots, or PDFs
|
||||
- WebGL or advanced CSS layout
|
||||
- Complete Web API coverage (Canvas, WebRTC, etc.)
|
||||
- Pixel-perfect visual testing
|
||||
NOTE: There are hundreds of Web APIs. Developing a browser (even just for headless mode) is a huge task. Coverage will increase over time.
|
||||
|
||||
### What Lightpanda supports today
|
||||
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.
|
||||
|
||||
- HTTP loader ([Libcurl](https://curl.se/libcurl/))
|
||||
- HTML parser ([html5ever](https://github.com/servo/html5ever))
|
||||
- DOM tree + DOM APIs
|
||||
- Javascript ([v8](https://v8.dev/))
|
||||
- Ajax (XHR + Fetch)
|
||||
- CDP/WebSocket server
|
||||
- Click, input/form, cookies
|
||||
- Custom HTTP headers
|
||||
- Proxy support
|
||||
- Network interception
|
||||
- robots.txt compliance (`--obey_robots`)
|
||||
|
||||
**Note:** There are hundreds of Web APIs. Coverage increases with each release. If you hit a gap, [open an issue](https://github.com/lightpanda-io/browser/issues).
|
||||
|
||||
## Use Cases
|
||||
|
||||
### AI Agents and LLM Tools
|
||||
|
||||
Give your AI agent a real browser that is fast and cheap to run. Lightpanda Cloud exposes an MCP endpoint at `cloud.lightpanda.io/mcp/sse` with tools for search, goto, markdown, and links. Works with Claude, Cursor, Windsurf, or any CDP-based agent framework.
|
||||
|
||||
- [agent-skill repo](https://github.com/lightpanda-io/agent-skill)
|
||||
|
||||
### Web Scraping and Data Extraction
|
||||
|
||||
Lightpanda uses 9x less memory than Chrome. It works with Crawlee, Puppeteer, and Playwright.
|
||||
|
||||
```console
|
||||
lightpanda fetch --dump markdown --obey_robots https://example.com
|
||||
```
|
||||
|
||||
### Automated Testing
|
||||
|
||||
Drop-in replacement for headless Chrome in CI pipelines. If your tests use Puppeteer or Playwright, change the connection URL to `ws://127.0.0.1:9222` and run them.
|
||||
|
||||
### LLM Training Data Collection
|
||||
|
||||
Use `--dump markdown` to extract clean text from JS-rendered pages at volume.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||
|
||||
The client connects over CDP via WebSocket. The server parses HTML into a DOM tree, applies CSS, and executes JavaScript through V8. Page content is returned to the client as HTML, markdown, or structured data depending on the request.
|
||||
|
||||
---
|
||||
|
||||
## Why Lightpanda?
|
||||
|
||||
### Javascript execution is mandatory for the modern web
|
||||
|
||||
Simple HTTP requests used to be enough for scraping. That's no longer the case. Javascript now drives most of the web:
|
||||
|
||||
- Ajax, Single Page Apps, infinite loading, instant search
|
||||
- JS frameworks: React, Vue, Angular, and others
|
||||
|
||||
### Chrome is not the right tool
|
||||
|
||||
Running a full desktop browser on a server works, but it does not scale well. Chrome at hundreds or thousands of instances is expensive:
|
||||
|
||||
- Heavy on RAM and CPU
|
||||
- Hard to package, deploy, and maintain at scale
|
||||
- Many features are irrelevant in headless usage
|
||||
|
||||
### Lightpanda is built for performance
|
||||
|
||||
Supporting Javascript with real performance meant building from scratch rather than forking Chromium:
|
||||
|
||||
- Not based on Chromium, Blink, or WebKit
|
||||
- Written in Zig, a low-level language with explicit memory control
|
||||
- No graphical rendering engine
|
||||
|
||||
---
|
||||
|
||||
## Build from Source
|
||||
## Build from sources
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Lightpanda is written with [Zig](https://ziglang.org/) `0.15.2` and depends on: [v8](https://chromium.googlesource.com/v8/v8.git), [Libcurl](https://curl.se/libcurl/), [html5ever](https://github.com/servo/html5ever).
|
||||
Lightpanda is written with [Zig](https://ziglang.org/) `0.14.1`. You have to
|
||||
install it with the right version in order to build the project.
|
||||
|
||||
**Debian/Ubuntu:**
|
||||
Lightpanda also depends on
|
||||
[zig-js-runtime](https://github.com/lightpanda-io/zig-js-runtime/) (with v8),
|
||||
[Netsurf libs](https://www.netsurf-browser.org/) and
|
||||
[Mimalloc](https://microsoft.github.io/mimalloc).
|
||||
|
||||
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
|
||||
|
||||
For Debian/Ubuntu based Linux:
|
||||
|
||||
```
|
||||
sudo apt install xz-utils ca-certificates \
|
||||
sudo apt install xz-utils \
|
||||
python3 ca-certificates git \
|
||||
pkg-config libglib2.0-dev \
|
||||
clang make curl git
|
||||
gperf libexpat1-dev unzip rsync \
|
||||
cmake clang
|
||||
```
|
||||
You also need to [install Rust](https://rust-lang.org/tools/install/).
|
||||
|
||||
**Nix:**
|
||||
|
||||
For systems with [Nix](https://nixos.org/download/), you can use the devShell:
|
||||
```
|
||||
nix develop
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
For MacOS, you only need cmake:
|
||||
|
||||
```
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
You also need [Rust](https://rust-lang.org/tools/install/).
|
||||
### Install and build dependencies
|
||||
|
||||
### Build and run
|
||||
#### All in one build
|
||||
|
||||
Build the browser:
|
||||
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.
|
||||
|
||||
#### Step by step build dependency
|
||||
|
||||
The project uses git submodules for dependencies.
|
||||
|
||||
To init or update the submodules in the `vendor/` directory:
|
||||
|
||||
```
|
||||
make build # release
|
||||
make build-dev # debug
|
||||
make install-submodule
|
||||
```
|
||||
|
||||
Or directly: `zig build run`.
|
||||
**iconv**
|
||||
|
||||
#### Embed v8 snapshot
|
||||
|
||||
Generate the snapshot:
|
||||
libiconv is an internationalization library used by Netsurf.
|
||||
|
||||
```
|
||||
zig build snapshot_creator -- src/snapshot.bin
|
||||
make install-libiconv
|
||||
```
|
||||
|
||||
Build using the snapshot:
|
||||
**Netsurf libs**
|
||||
|
||||
Netsurf libs are used for HTML parsing and DOM tree generation.
|
||||
|
||||
```
|
||||
zig build -Dsnapshot_path=../../snapshot.bin
|
||||
make install-netsurf
|
||||
```
|
||||
|
||||
See [#1279](https://github.com/lightpanda-io/browser/pull/1279) for details.
|
||||
For dev env, use `make install-netsurf-dev`.
|
||||
|
||||
---
|
||||
**Mimalloc**
|
||||
|
||||
Mimalloc is used as a C memory allocator.
|
||||
|
||||
```
|
||||
make install-mimalloc
|
||||
```
|
||||
|
||||
For dev env, use `make install-mimalloc-dev`.
|
||||
|
||||
Note: when Mimalloc is built in dev mode, you can dump memory stats with the
|
||||
env var `MIMALLOC_SHOW_STATS=1`. See
|
||||
[https://microsoft.github.io/mimalloc/environment.html](https://microsoft.github.io/mimalloc/environment.html).
|
||||
|
||||
**v8**
|
||||
|
||||
First, get the tools necessary for building V8, as well as the V8 source code:
|
||||
|
||||
```
|
||||
make get-v8
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
## Test
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```
|
||||
make test
|
||||
```
|
||||
You can test Lightpanda by running `make test`.
|
||||
|
||||
### End to End Tests
|
||||
### End to end tests
|
||||
|
||||
Clone the [demo repository](https://github.com/lightpanda-io/demo) into `../demo`. Install the [demo's node requirements](https://github.com/lightpanda-io/demo?tab=readme-ov-file#dependencies-1) and [Go](https://go.dev) >= v1.24.
|
||||
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
|
||||
@@ -366,124 +267,67 @@ make end2end
|
||||
|
||||
### Web Platform Tests
|
||||
|
||||
Lightpanda is tested against the standardized [Web Platform Tests](https://web-platform-tests.org/) using [a fork](https://github.com/lightpanda-io/wpt/tree/fork) with a custom [`testharnessreport.js`](https://github.com/lightpanda-io/wpt/commit/01a3115c076a3ad0c84849dbbf77a6e3d199c56f).
|
||||
Lightpanda is tested against the standardized [Web Platform
|
||||
Tests](https://web-platform-tests.org/).
|
||||
|
||||
You can also run individual WPT test cases in your browser via [wpt.live](https://wpt.live).
|
||||
The relevant tests cases are committed in a [dedicated repository](https://github.com/lightpanda-io/wpt) which is fetched by the `make install-submodule` command.
|
||||
|
||||
**Setup WPT HTTP server:**
|
||||
All the tests cases executed are located in the `tests/wpt` sub-directory.
|
||||
|
||||
For reference, you can easily execute a WPT test case with your browser via
|
||||
[wpt.live](https://wpt.live).
|
||||
|
||||
#### Run WPT test suite
|
||||
|
||||
To run all the tests:
|
||||
|
||||
```
|
||||
git clone -b fork --depth=1 git@github.com:lightpanda-io/wpt.git
|
||||
cd wpt
|
||||
./wpt make-hosts-file | sudo tee -a /etc/hosts
|
||||
./wpt manifest
|
||||
make wpt
|
||||
```
|
||||
|
||||
See the [WPT setup guide](https://web-platform-tests.org/running-tests/from-local-system.html) for details.
|
||||
|
||||
**Run WPT tests:**
|
||||
|
||||
Start the WPT HTTP server:
|
||||
Or one specific test:
|
||||
|
||||
```
|
||||
./wpt serve
|
||||
make wpt Node-childNodes.html
|
||||
```
|
||||
|
||||
Run Lightpanda:
|
||||
#### Add a new WPT test case
|
||||
|
||||
```
|
||||
zig build run -- --insecure_disable_tls_host_verification
|
||||
```
|
||||
We add new relevant tests cases files when we implemented changes in Lightpanda.
|
||||
|
||||
Run the test suite (from [demo](https://github.com/lightpanda-io/demo/) clone):
|
||||
To add a new test, copy the file you want from the [WPT
|
||||
repo](https://github.com/web-platform-tests/wpt) into the `tests/wpt` directory.
|
||||
|
||||
```
|
||||
cd wptrunner && go run .
|
||||
```
|
||||
|
||||
Run a specific test:
|
||||
|
||||
```
|
||||
cd wptrunner && go run . Node-childNodes.html
|
||||
```
|
||||
|
||||
Options: `--summary`, `--json`, `--concurrency`.
|
||||
|
||||
**Note:** The full suite takes a long time. Build with `zig build -Doptimize=ReleaseFast run` for faster test execution.
|
||||
|
||||
---
|
||||
:warning: Please keep the original directory tree structure of `tests/wpt`.
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](https://github.com/lightpanda-io/browser/blob/main/CONTRIBUTING.md) for guidelines.
|
||||
Lightpanda accepts pull requests through GitHub.
|
||||
|
||||
You must sign our [CLA](CLA.md) during the pull request process.
|
||||
You have to sign our [CLA](CLA.md) during the pull request process otherwise
|
||||
we're not able to accept your contributions.
|
||||
|
||||
- [Good first issues](https://github.com/lightpanda-io/browser/labels/good%20first%20issue)
|
||||
- [Discord](https://discord.gg/K63XeymfB5)
|
||||
## Why?
|
||||
|
||||
---
|
||||
### Javascript execution is mandatory for the modern web
|
||||
|
||||
## Compatibility Note
|
||||
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:
|
||||
|
||||
**Playwright compatibility note:** A Playwright script that works today may break after a Lightpanda update. Playwright selects its execution strategy based on which browser APIs are available. When Lightpanda adds a new [Web API](https://developer.mozilla.org/en-US/docs/Web/API), Playwright may switch to a code path that uses features not yet implemented. We test for compatibility, but cannot cover every scenario. If you hit a regression, [open a GitHub issue](https://github.com/lightpanda-io/browser/issues) and include the last version of the script that worked.
|
||||
- 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
|
||||
|
||||
## FAQ
|
||||
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?
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: What is Lightpanda?</strong></summary>
|
||||
- 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 an open-source headless browser written in Zig. It targets AI agents, web scraping, and automated testing. It uses 9x less memory and runs 11x faster than headless Chrome.
|
||||
### Lightpanda is built for performance
|
||||
|
||||
</details>
|
||||
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:
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: How does Lightpanda compare to Headless Chrome?</strong></summary>
|
||||
|
||||
About 24 MB peak memory vs 207 MB for Chrome when loading 100 pages via Puppeteer. Task completion: 2.3s vs 25.2s. It supports the same CDP protocol, so most Puppeteer and Playwright scripts work without code changes. See the [Lightpanda vs Headless Chrome](#lightpanda-vs-headless-chrome) section for what Lightpanda can and cannot do.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: Is Lightpanda a Chromium fork?</strong></summary>
|
||||
|
||||
No. It is written in Zig and implements web standards independently (W3C DOM, CSS, JavaScript via V8).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: Does Lightpanda work with Playwright?</strong></summary>
|
||||
|
||||
Yes. Connect with `chromium.connectOverCDP("ws://127.0.0.1:9222")` locally, or use a cloud endpoint for managed infrastructure. See the [compatibility note](#compatibility-note) for caveats.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: Is there a cloud/hosted version?</strong></summary>
|
||||
|
||||
Yes. [console.lightpanda.io](https://console.lightpanda.io) provides managed browser infrastructure with regional endpoints (EU West, US West), MCP integration, and both Lightpanda and Chromium browser options.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: Why is Lightpanda written in Zig?</strong></summary>
|
||||
|
||||
Zig provides precise memory control and deterministic performance without a garbage collector. It compiles to a single static binary with no runtime dependencies. Learn more: [Why We Built Lightpanda in Zig](https://lightpanda.io/blog/posts/why-we-built-lightpanda-in-zig).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: What operating systems does Lightpanda support?</strong></summary>
|
||||
|
||||
Linux (Debian 12, Ubuntu 22.04/24.04), macOS 13+, and Windows 10+ via WSL2.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Q: Does Lightpanda respect robots.txt?</strong></summary>
|
||||
|
||||
Yes, when the `--obey_robots` flag is enabled.
|
||||
|
||||
</details>
|
||||
- Not based on Chromium, Blink or WebKit
|
||||
- Low-level system programming language (Zig) with optimisations in mind
|
||||
- Opinionated: without graphical rendering
|
||||
|
||||
29
SUMMARY.md
29
SUMMARY.md
@@ -1,29 +0,0 @@
|
||||
# Lightpanda Browser: Document Summary
|
||||
|
||||
**What it is:** A headless browser built in Zig from scratch. Not a Chromium fork. Targets AI agents, scraping, and automated testing.
|
||||
|
||||
**Performance:** 9x less memory (24 MB vs 207 MB) and 11x faster (2.3s vs 25.2s) than headless Chrome, measured over 100 pages via Puppeteer.
|
||||
|
||||
---
|
||||
|
||||
## Section Summaries
|
||||
|
||||
**Quick Start:** Install via nightly binary (Linux/macOS/Windows WSL2) or Docker. Run `fetch` to dump a URL or `serve` to start a CDP server. Connect Puppeteer/Playwright via `ws://127.0.0.1:9222`.
|
||||
|
||||
**Lightpanda vs Headless Chrome:** Choose Lightpanda for low-memory scraping, AI agent browsing, CI testing, and markdown extraction. Use Chrome for screenshots, PDFs, WebGL, or full Web API coverage. Supported: HTTP, HTML5, DOM, JS (V8), Ajax, CDP, cookies, proxy, network interception, robots.txt.
|
||||
|
||||
**Use Cases:** AI agents via MCP or CDP, web scraping at scale, headless Chrome replacement in CI, LLM training data extraction with `--dump markdown`.
|
||||
|
||||
**Architecture:** CDP/WebSocket client → HTML parsed to DOM → CSS applied → JS via V8 → response as HTML, markdown, or structured data.
|
||||
|
||||
**Why Lightpanda?:** Modern web requires JS execution; Chrome is too heavy to run at scale; Lightpanda is built in Zig with no graphical renderer for minimal footprint.
|
||||
|
||||
**Build from Source:** Requires Zig 0.15.2, v8, Libcurl, html5ever, and Rust. `make build` or `zig build run`. Optional v8 snapshot for faster startup.
|
||||
|
||||
**Test:** `make test` for unit tests; `make end2end` for end-to-end; WPT suite runs via a Go runner in the demo repo.
|
||||
|
||||
**Contributing:** PRs via GitHub; CLA required. [Good first issues](https://github.com/lightpanda-io/browser/labels/good%20first%20issue) labeled.
|
||||
|
||||
**Compatibility Note:** Playwright scripts may break after Lightpanda updates when new Web APIs shift Playwright's execution path. File an issue with the last working version.
|
||||
|
||||
**FAQ:** What Lightpanda is, Chrome comparison, Chromium fork question, Playwright/cloud usage, Zig rationale, OS support, robots.txt.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 342 KiB |
807
build.zig
807
build.zig
@@ -17,711 +17,236 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const Build = std.Build;
|
||||
/// Do not rename this constant. It is scanned by some scripts to determine
|
||||
/// which zig version to install.
|
||||
const recommended_zig_version = "0.14.1";
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
switch (comptime builtin.zig_version.order(std.SemanticVersion.parse(recommended_zig_version) catch unreachable)) {
|
||||
.eq => {},
|
||||
.lt => {
|
||||
@compileError("The minimum version of Zig required to compile is '" ++ recommended_zig_version ++ "', found '" ++ builtin.zig_version_string ++ "'.");
|
||||
},
|
||||
.gt => {
|
||||
std.debug.print(
|
||||
"WARNING: Recommended Zig version '{s}', but found '{s}', build may fail...\n\n",
|
||||
.{ recommended_zig_version, builtin.zig_version_string },
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
var opts = b.addOptions();
|
||||
opts.addOption(
|
||||
[]const u8,
|
||||
"git_commit",
|
||||
b.option([]const u8, "git_commit", "Current git commit") orelse "dev",
|
||||
);
|
||||
|
||||
pub fn build(b: *Build) !void {
|
||||
const target = b.standardTargetOptions(.{});
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
|
||||
const manifest = Manifest.init(b);
|
||||
|
||||
const git_commit = b.option([]const u8, "git_commit", "Current git commit");
|
||||
const git_version = b.option([]const u8, "git_version", "Current git version (from tag)");
|
||||
const prebuilt_v8_path = b.option([]const u8, "prebuilt_v8_path", "Path to prebuilt libc_v8.a");
|
||||
const snapshot_path = b.option([]const u8, "snapshot_path", "Path to v8 snapshot");
|
||||
|
||||
var opts = b.addOptions();
|
||||
opts.addOption([]const u8, "version", manifest.version);
|
||||
opts.addOption([]const u8, "git_commit", git_commit orelse "dev");
|
||||
opts.addOption(?[]const u8, "git_version", git_version orelse null);
|
||||
opts.addOption(?[]const u8, "snapshot_path", snapshot_path);
|
||||
|
||||
const enable_tsan = b.option(bool, "tsan", "Enable Thread Sanitizer") orelse false;
|
||||
const enable_asan = b.option(bool, "asan", "Enable Address Sanitizer") orelse false;
|
||||
const enable_csan = b.option(std.zig.SanitizeC, "csan", "Enable C Sanitizers");
|
||||
|
||||
const lightpanda_module = blk: {
|
||||
const mod = b.addModule("lightpanda", .{
|
||||
.root_source_file = b.path("src/lightpanda.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.link_libcpp = true,
|
||||
.sanitize_c = enable_csan,
|
||||
.sanitize_thread = enable_tsan,
|
||||
});
|
||||
mod.addImport("lightpanda", mod); // allow circular "lightpanda" import
|
||||
mod.addImport("build_config", opts.createModule());
|
||||
|
||||
// Format check
|
||||
const fmt_step = b.step("fmt", "Check code formatting");
|
||||
const fmt = b.addFmt(.{
|
||||
.paths = &.{ "src", "build.zig", "build.zig.zon" },
|
||||
.check = true,
|
||||
});
|
||||
fmt_step.dependOn(&fmt.step);
|
||||
|
||||
// Set default behavior
|
||||
b.default_step.dependOn(fmt_step);
|
||||
|
||||
try linkV8(b, mod, enable_asan, enable_tsan, prebuilt_v8_path);
|
||||
try linkCurl(b, mod, enable_tsan);
|
||||
try linkHtml5Ever(b, mod);
|
||||
|
||||
break :blk mod;
|
||||
};
|
||||
|
||||
{
|
||||
// browser
|
||||
// -------
|
||||
|
||||
// compile and install
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "lightpanda",
|
||||
.use_llvm = true,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.sanitize_c = enable_csan,
|
||||
.sanitize_thread = enable_tsan,
|
||||
.imports = &.{
|
||||
.{ .name = "lightpanda", .module = lightpanda_module },
|
||||
},
|
||||
}),
|
||||
.root_source_file = b.path("src/main.zig"),
|
||||
});
|
||||
|
||||
try common(b, opts, exe);
|
||||
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);
|
||||
}
|
||||
|
||||
{
|
||||
// snapshot creator
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "lightpanda-snapshot-creator",
|
||||
.use_llvm = true,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main_snapshot_creator.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.imports = &.{
|
||||
.{ .name = "lightpanda", .module = lightpanda_module },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
}
|
||||
const run_step = b.step("snapshot_creator", "Generate a v8 snapshot");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
// 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);
|
||||
}
|
||||
|
||||
{
|
||||
// test
|
||||
// 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);
|
||||
}
|
||||
|
||||
{
|
||||
// tests
|
||||
// ----
|
||||
|
||||
// compile
|
||||
const tests = b.addTest(.{
|
||||
.root_module = lightpanda_module,
|
||||
.use_llvm = true,
|
||||
.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);
|
||||
|
||||
const run_tests = b.addRunArtifact(tests);
|
||||
const test_step = b.step("test", "Run unit tests");
|
||||
test_step.dependOn(&run_tests.step);
|
||||
if (b.args) |args| {
|
||||
run_tests.addArgs(args);
|
||||
}
|
||||
|
||||
// step
|
||||
const tests_step = b.step("test", "Run unit tests");
|
||||
tests_step.dependOn(&run_tests.step);
|
||||
}
|
||||
|
||||
{
|
||||
// browser
|
||||
const exe = b.addExecutable(.{
|
||||
.name = "legacy_test",
|
||||
.use_llvm = true,
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("src/main_legacy_test.zig"),
|
||||
// wpt
|
||||
// -----
|
||||
|
||||
// compile and install
|
||||
const wpt = b.addExecutable(.{
|
||||
.name = "lightpanda-wpt",
|
||||
.root_source_file = b.path("src/main_wpt.zig"),
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.sanitize_c = enable_csan,
|
||||
.sanitize_thread = enable_tsan,
|
||||
.imports = &.{
|
||||
.{ .name = "lightpanda", .module = lightpanda_module },
|
||||
},
|
||||
}),
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
try common(b, opts, wpt);
|
||||
|
||||
const run_cmd = b.addRunArtifact(exe);
|
||||
// run
|
||||
const wpt_cmd = b.addRunArtifact(wpt);
|
||||
if (b.args) |args| {
|
||||
run_cmd.addArgs(args);
|
||||
wpt_cmd.addArgs(args);
|
||||
}
|
||||
const run_step = b.step("legacy_test", "Run the app");
|
||||
run_step.dependOn(&run_cmd.step);
|
||||
// step
|
||||
const wpt_step = b.step("wpt", "WPT tests");
|
||||
wpt_step.dependOn(&wpt_cmd.step);
|
||||
}
|
||||
}
|
||||
|
||||
fn linkV8(
|
||||
b: *Build,
|
||||
mod: *Build.Module,
|
||||
is_asan: bool,
|
||||
is_tsan: bool,
|
||||
prebuilt_v8_path: ?[]const u8,
|
||||
) !void {
|
||||
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 };
|
||||
|
||||
const dep = b.dependency("v8", .{
|
||||
.target = target,
|
||||
.optimize = mod.optimize.?,
|
||||
.is_asan = is_asan,
|
||||
.is_tsan = is_tsan,
|
||||
.inspector_subtype = false,
|
||||
.v8_enable_sandbox = is_tsan,
|
||||
.cache_root = b.pathFromRoot(".lp-cache"),
|
||||
.prebuilt_v8_path = prebuilt_v8_path,
|
||||
});
|
||||
mod.addImport("v8", dep.module("v8"));
|
||||
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"));
|
||||
|
||||
{
|
||||
// v8
|
||||
const v8_opts = b.addOptions();
|
||||
v8_opts.addOption(bool, "inspector_subtype", false);
|
||||
|
||||
const v8_mod = b.dependency("v8", dep_opts).module("v8");
|
||||
v8_mod.addOptions("default_exports", v8_opts);
|
||||
mod.addImport("v8", v8_mod);
|
||||
}
|
||||
|
||||
fn linkHtml5Ever(b: *Build, mod: *Build.Module) !void {
|
||||
const is_debug = if (mod.optimize.? == .Debug) true else false;
|
||||
mod.link_libcpp = true;
|
||||
|
||||
const exec_cargo = b.addSystemCommand(&.{
|
||||
"cargo", "build",
|
||||
"--profile", if (is_debug) "dev" else "release",
|
||||
"--manifest-path", "src/html5ever/Cargo.toml",
|
||||
});
|
||||
|
||||
// TODO: We can prefer `--artifact-dir` once it become stable.
|
||||
const out_dir = exec_cargo.addPrefixedOutputDirectoryArg("--target-dir=", "html5ever");
|
||||
|
||||
const html5ever_step = b.step("html5ever", "Install html5ever dependency (requires cargo)");
|
||||
html5ever_step.dependOn(&exec_cargo.step);
|
||||
|
||||
const obj = out_dir.path(b, if (is_debug) "debug" else "release").path(b, "liblitefetch_html5ever.a");
|
||||
mod.addObjectFile(obj);
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
fn linkCurl(b: *Build, mod: *Build.Module, is_tsan: bool) !void {
|
||||
const target = mod.resolved_target.?;
|
||||
|
||||
const curl = buildCurl(b, target, mod.optimize.?, is_tsan);
|
||||
mod.linkLibrary(curl);
|
||||
|
||||
const zlib = buildZlib(b, target, mod.optimize.?, is_tsan);
|
||||
curl.root_module.linkLibrary(zlib);
|
||||
|
||||
const brotli = buildBrotli(b, target, mod.optimize.?, is_tsan);
|
||||
for (brotli) |lib| curl.root_module.linkLibrary(lib);
|
||||
|
||||
const nghttp2 = buildNghttp2(b, target, mod.optimize.?, is_tsan);
|
||||
curl.root_module.linkLibrary(nghttp2);
|
||||
|
||||
const boringssl = buildBoringSsl(b, target, mod.optimize.?);
|
||||
for (boringssl) |lib| curl.root_module.linkLibrary(lib);
|
||||
|
||||
switch (target.result.os.tag) {
|
||||
.macos => {
|
||||
// needed for proxying on mac
|
||||
// v8 has a dependency, abseil-cpp, which, on Mac, uses CoreFoundation
|
||||
mod.addSystemFrameworkPath(.{ .cwd_relative = "/System/Library/Frameworks" });
|
||||
mod.linkFramework("CoreFoundation", .{});
|
||||
mod.linkFramework("SystemConfiguration", .{});
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
mod.addImport("build_config", opts.createModule());
|
||||
}
|
||||
|
||||
fn buildZlib(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
|
||||
const dep = b.dependency("zlib", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
|
||||
const lib = b.addLibrary(.{ .name = "z", .root_module = mod });
|
||||
lib.installHeadersDirectory(dep.path(""), "", .{});
|
||||
lib.addCSourceFiles(.{
|
||||
.root = dep.path(""),
|
||||
.flags = &.{
|
||||
"-DHAVE_SYS_TYPES_H",
|
||||
"-DHAVE_STDINT_H",
|
||||
"-DHAVE_STDDEF_H",
|
||||
"-DHAVE_UNISTD_H",
|
||||
},
|
||||
.files = &.{
|
||||
"adler32.c", "compress.c", "crc32.c",
|
||||
"deflate.c", "gzclose.c", "gzlib.c",
|
||||
"gzread.c", "gzwrite.c", "infback.c",
|
||||
"inffast.c", "inflate.c", "inftrees.c",
|
||||
"trees.c", "uncompr.c", "zutil.c",
|
||||
},
|
||||
});
|
||||
|
||||
return lib;
|
||||
}
|
||||
|
||||
fn buildBrotli(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) [3]*Build.Step.Compile {
|
||||
const dep = b.dependency("brotli", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("c/include"));
|
||||
|
||||
const brotlicmn = b.addLibrary(.{ .name = "brotlicommon", .root_module = mod });
|
||||
const brotlidec = b.addLibrary(.{ .name = "brotlidec", .root_module = mod });
|
||||
const brotlienc = b.addLibrary(.{ .name = "brotlienc", .root_module = mod });
|
||||
|
||||
brotlicmn.installHeadersDirectory(dep.path("c/include/brotli"), "brotli", .{});
|
||||
brotlicmn.addCSourceFiles(.{
|
||||
.root = dep.path("c/common"),
|
||||
.files = &.{
|
||||
"transform.c", "shared_dictionary.c", "platform.c",
|
||||
"dictionary.c", "context.c", "constants.c",
|
||||
},
|
||||
});
|
||||
brotlidec.addCSourceFiles(.{
|
||||
.root = dep.path("c/dec"),
|
||||
.files = &.{
|
||||
"bit_reader.c", "decode.c", "huffman.c",
|
||||
"prefix.c", "state.c", "static_init.c",
|
||||
},
|
||||
});
|
||||
brotlienc.addCSourceFiles(.{
|
||||
.root = dep.path("c/enc"),
|
||||
.files = &.{
|
||||
"backward_references.c", "backward_references_hq.c", "bit_cost.c",
|
||||
"block_splitter.c", "brotli_bit_stream.c", "cluster.c",
|
||||
"command.c", "compound_dictionary.c", "compress_fragment.c",
|
||||
"compress_fragment_two_pass.c", "dictionary_hash.c", "encode.c",
|
||||
"encoder_dict.c", "entropy_encode.c", "fast_log.c",
|
||||
"histogram.c", "literal_cost.c", "memory.c",
|
||||
"metablock.c", "static_dict.c", "static_dict_lut.c",
|
||||
"static_init.c", "utf8_util.c",
|
||||
},
|
||||
});
|
||||
|
||||
return .{ brotlicmn, brotlidec, brotlienc };
|
||||
}
|
||||
|
||||
fn buildBoringSsl(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) [2]*Build.Step.Compile {
|
||||
const dep = b.dependency("boringssl-zig", .{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.force_pic = true,
|
||||
});
|
||||
|
||||
const ssl = dep.artifact("ssl");
|
||||
ssl.bundle_ubsan_rt = false;
|
||||
|
||||
const crypto = dep.artifact("crypto");
|
||||
crypto.bundle_ubsan_rt = false;
|
||||
|
||||
return .{ ssl, crypto };
|
||||
}
|
||||
|
||||
fn buildNghttp2(b: *Build, target: Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, is_tsan: bool) *Build.Step.Compile {
|
||||
const dep = b.dependency("nghttp2", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("lib/includes"));
|
||||
|
||||
const config = b.addConfigHeader(.{
|
||||
.include_path = "nghttp2ver.h",
|
||||
.style = .{ .cmake = dep.path("lib/includes/nghttp2/nghttp2ver.h.in") },
|
||||
}, .{
|
||||
.PACKAGE_VERSION = "1.68.90",
|
||||
.PACKAGE_VERSION_NUM = 0x016890,
|
||||
});
|
||||
mod.addConfigHeader(config);
|
||||
|
||||
const lib = b.addLibrary(.{ .name = "nghttp2", .root_module = mod });
|
||||
|
||||
lib.installConfigHeader(config);
|
||||
lib.installHeadersDirectory(dep.path("lib/includes/nghttp2"), "nghttp2", .{});
|
||||
lib.addCSourceFiles(.{
|
||||
.root = dep.path("lib"),
|
||||
.flags = &.{
|
||||
"-DNGHTTP2_STATICLIB",
|
||||
"-DHAVE_TIME_H",
|
||||
"-DHAVE_ARPA_INET_H",
|
||||
"-DHAVE_NETINET_IN_H",
|
||||
},
|
||||
.files = &.{
|
||||
"sfparse.c", "nghttp2_alpn.c", "nghttp2_buf.c",
|
||||
"nghttp2_callbacks.c", "nghttp2_debug.c", "nghttp2_extpri.c",
|
||||
"nghttp2_frame.c", "nghttp2_hd.c", "nghttp2_hd_huffman.c",
|
||||
"nghttp2_hd_huffman_data.c", "nghttp2_helper.c", "nghttp2_http.c",
|
||||
"nghttp2_map.c", "nghttp2_mem.c", "nghttp2_option.c",
|
||||
"nghttp2_outbound_item.c", "nghttp2_pq.c", "nghttp2_priority_spec.c",
|
||||
"nghttp2_queue.c", "nghttp2_rcbuf.c", "nghttp2_session.c",
|
||||
"nghttp2_stream.c", "nghttp2_submit.c", "nghttp2_version.c",
|
||||
"nghttp2_ratelim.c", "nghttp2_time.c",
|
||||
},
|
||||
});
|
||||
|
||||
return lib;
|
||||
}
|
||||
|
||||
fn buildCurl(
|
||||
b: *Build,
|
||||
target: Build.ResolvedTarget,
|
||||
optimize: std.builtin.OptimizeMode,
|
||||
is_tsan: bool,
|
||||
) *Build.Step.Compile {
|
||||
const dep = b.dependency("curl", .{});
|
||||
|
||||
const mod = b.createModule(.{
|
||||
.target = target,
|
||||
.optimize = optimize,
|
||||
.link_libc = true,
|
||||
.sanitize_thread = is_tsan,
|
||||
});
|
||||
mod.addIncludePath(dep.path("lib"));
|
||||
mod.addIncludePath(dep.path("include"));
|
||||
|
||||
fn moduleNetSurf(b: *std.Build, step: *std.Build.Step.Compile, target: std.Build.ResolvedTarget) !void {
|
||||
const os = target.result.os.tag;
|
||||
const abi = target.result.abi;
|
||||
const arch = target.result.cpu.arch;
|
||||
|
||||
const is_gnu = abi.isGnu();
|
||||
const is_ios = os == .ios;
|
||||
const is_android = abi.isAndroid();
|
||||
const is_linux = os == .linux;
|
||||
const is_darwin = os.isDarwin();
|
||||
const is_windows = os == .windows;
|
||||
const is_netbsd = os == .netbsd;
|
||||
const is_openbsd = os == .openbsd;
|
||||
const is_freebsd = os == .freebsd;
|
||||
// iconv
|
||||
const libiconv_lib_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
const libiconv_include_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
"vendor/libiconv/out/{s}-{s}/lib/libiconv.a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addObjectFile(b.path(libiconv_lib_path));
|
||||
step.addIncludePath(b.path(libiconv_include_path));
|
||||
|
||||
const byte_size = struct {
|
||||
fn it(b2: *std.Build, target2: Build.ResolvedTarget, name: []const u8, comptime ctype: std.Target.CType) []const u8 {
|
||||
const size = target2.result.cTypeByteSize(ctype);
|
||||
return std.fmt.allocPrint(b2.allocator, "#define SIZEOF_{s} {d}", .{ name, size }) catch @panic("OOM");
|
||||
{
|
||||
// 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"));
|
||||
}
|
||||
}.it;
|
||||
|
||||
const config = .{
|
||||
.HAVE_LIBZ = true,
|
||||
.HAVE_BROTLI = true,
|
||||
.USE_NGHTTP2 = true,
|
||||
// netsurf libs
|
||||
const ns = "vendor/netsurf";
|
||||
const ns_include_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
ns ++ "/out/{s}-{s}/include",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addIncludePath(b.path(ns_include_path));
|
||||
|
||||
.USE_OPENSSL = true,
|
||||
.OPENSSL_IS_BORINGSSL = true,
|
||||
.CURL_CA_PATH = null,
|
||||
.CURL_CA_BUNDLE = null,
|
||||
.CURL_CA_FALLBACK = false,
|
||||
.CURL_CA_SEARCH_SAFE = false,
|
||||
.CURL_DEFAULT_SSL_BACKEND = "openssl",
|
||||
|
||||
.CURL_DISABLE_AWS = true,
|
||||
.CURL_DISABLE_DICT = true,
|
||||
.CURL_DISABLE_DOH = true,
|
||||
.CURL_DISABLE_FILE = true,
|
||||
.CURL_DISABLE_FTP = true,
|
||||
.CURL_DISABLE_GOPHER = true,
|
||||
.CURL_DISABLE_KERBEROS_AUTH = true,
|
||||
.CURL_DISABLE_IMAP = true,
|
||||
.CURL_DISABLE_IPFS = true,
|
||||
.CURL_DISABLE_LDAP = true,
|
||||
.CURL_DISABLE_LDAPS = true,
|
||||
.CURL_DISABLE_MQTT = true,
|
||||
.CURL_DISABLE_NTLM = true,
|
||||
.CURL_DISABLE_PROGRESS_METER = true,
|
||||
.CURL_DISABLE_POP3 = true,
|
||||
.CURL_DISABLE_RTSP = true,
|
||||
.CURL_DISABLE_SMB = true,
|
||||
.CURL_DISABLE_SMTP = true,
|
||||
.CURL_DISABLE_TELNET = true,
|
||||
.CURL_DISABLE_TFTP = true,
|
||||
|
||||
.ssize_t = null,
|
||||
._FILE_OFFSET_BITS = 64,
|
||||
|
||||
.USE_IPV6 = true,
|
||||
.CURL_OS = switch (os) {
|
||||
.linux => if (is_android) "\"android\"" else "\"linux\"",
|
||||
else => std.fmt.allocPrint(b.allocator, "\"{s}\"", .{@tagName(os)}) catch @panic("OOM"),
|
||||
},
|
||||
|
||||
// Adjusts the sizes of variables
|
||||
.SIZEOF_INT_CODE = byte_size(b, target, "INT", .int),
|
||||
.SIZEOF_LONG_CODE = byte_size(b, target, "LONG", .long),
|
||||
.SIZEOF_LONG_LONG_CODE = byte_size(b, target, "LONG_LONG", .longlong),
|
||||
|
||||
.SIZEOF_OFF_T_CODE = byte_size(b, target, "OFF_T", .longlong),
|
||||
.SIZEOF_CURL_OFF_T_CODE = byte_size(b, target, "CURL_OFF_T", .longlong),
|
||||
.SIZEOF_CURL_SOCKET_T_CODE = byte_size(b, target, "CURL_SOCKET_T", .int),
|
||||
|
||||
.SIZEOF_SIZE_T_CODE = byte_size(b, target, "SIZE_T", .longlong),
|
||||
.SIZEOF_TIME_T_CODE = byte_size(b, target, "TIME_T", .longlong),
|
||||
|
||||
// headers availability
|
||||
.HAVE_ARPA_INET_H = !is_windows,
|
||||
.HAVE_DIRENT_H = true,
|
||||
.HAVE_FCNTL_H = true,
|
||||
.HAVE_IFADDRS_H = !is_windows,
|
||||
.HAVE_IO_H = is_windows,
|
||||
.HAVE_LIBGEN_H = true,
|
||||
.HAVE_LINUX_TCP_H = is_linux and is_gnu,
|
||||
.HAVE_LOCALE_H = true,
|
||||
.HAVE_NETDB_H = !is_windows,
|
||||
.HAVE_NETINET_IN6_H = is_android,
|
||||
.HAVE_NETINET_IN_H = !is_windows,
|
||||
.HAVE_NETINET_TCP_H = !is_windows,
|
||||
.HAVE_NETINET_UDP_H = !is_windows,
|
||||
.HAVE_NET_IF_H = !is_windows,
|
||||
.HAVE_POLL_H = !is_windows,
|
||||
.HAVE_PWD_H = !is_windows,
|
||||
.HAVE_STDATOMIC_H = true,
|
||||
.HAVE_STDBOOL_H = true,
|
||||
.HAVE_STDDEF_H = true,
|
||||
.HAVE_STDINT_H = true,
|
||||
.HAVE_STRINGS_H = true,
|
||||
.HAVE_STROPTS_H = false,
|
||||
.HAVE_SYS_EVENTFD_H = is_linux or is_freebsd or is_netbsd,
|
||||
.HAVE_SYS_FILIO_H = !is_linux and !is_windows,
|
||||
.HAVE_SYS_IOCTL_H = !is_windows,
|
||||
.HAVE_SYS_PARAM_H = true,
|
||||
.HAVE_SYS_POLL_H = !is_windows,
|
||||
.HAVE_SYS_RESOURCE_H = !is_windows,
|
||||
.HAVE_SYS_SELECT_H = !is_windows,
|
||||
.HAVE_SYS_SOCKIO_H = !is_linux and !is_windows,
|
||||
.HAVE_SYS_TYPES_H = true,
|
||||
.HAVE_SYS_UN_H = !is_windows,
|
||||
.HAVE_SYS_UTIME_H = is_windows,
|
||||
.HAVE_TERMIOS_H = !is_windows,
|
||||
.HAVE_TERMIO_H = is_linux,
|
||||
.HAVE_UNISTD_H = true,
|
||||
.HAVE_UTIME_H = true,
|
||||
.STDC_HEADERS = true,
|
||||
|
||||
// general environment
|
||||
.CURL_KRB5_VERSION = null,
|
||||
.HAVE_ALARM = !is_windows,
|
||||
.HAVE_ARC4RANDOM = is_android,
|
||||
.HAVE_ATOMIC = true,
|
||||
.HAVE_BOOL_T = true,
|
||||
.HAVE_BUILTIN_AVAILABLE = true,
|
||||
.HAVE_CLOCK_GETTIME_MONOTONIC = !is_darwin and !is_windows,
|
||||
.HAVE_CLOCK_GETTIME_MONOTONIC_RAW = is_linux,
|
||||
.HAVE_FILE_OFFSET_BITS = true,
|
||||
.HAVE_GETEUID = !is_windows,
|
||||
.HAVE_GETPPID = !is_windows,
|
||||
.HAVE_GETTIMEOFDAY = true,
|
||||
.HAVE_GLIBC_STRERROR_R = is_gnu,
|
||||
.HAVE_GMTIME_R = !is_windows,
|
||||
.HAVE_LOCALTIME_R = !is_windows,
|
||||
.HAVE_LONGLONG = !is_windows,
|
||||
.HAVE_MACH_ABSOLUTE_TIME = is_darwin,
|
||||
.HAVE_MEMRCHR = !is_darwin and !is_windows,
|
||||
.HAVE_POSIX_STRERROR_R = !is_gnu and !is_windows,
|
||||
.HAVE_PTHREAD_H = !is_windows,
|
||||
.HAVE_SETLOCALE = true,
|
||||
.HAVE_SETRLIMIT = !is_windows,
|
||||
.HAVE_SIGACTION = !is_windows,
|
||||
.HAVE_SIGINTERRUPT = !is_windows,
|
||||
.HAVE_SIGNAL = true,
|
||||
.HAVE_SIGSETJMP = !is_windows,
|
||||
.HAVE_SIZEOF_SA_FAMILY_T = false,
|
||||
.HAVE_SIZEOF_SUSECONDS_T = false,
|
||||
.HAVE_SNPRINTF = true,
|
||||
.HAVE_STRCASECMP = !is_windows,
|
||||
.HAVE_STRCMPI = false,
|
||||
.HAVE_STRDUP = true,
|
||||
.HAVE_STRERROR_R = !is_windows,
|
||||
.HAVE_STRICMP = false,
|
||||
.HAVE_STRUCT_TIMEVAL = true,
|
||||
.HAVE_TIME_T_UNSIGNED = false,
|
||||
.HAVE_UTIME = true,
|
||||
.HAVE_UTIMES = !is_windows,
|
||||
.HAVE_WRITABLE_ARGV = !is_windows,
|
||||
.HAVE__SETMODE = is_windows,
|
||||
.USE_THREADS_POSIX = !is_windows,
|
||||
|
||||
// filesystem, network
|
||||
.HAVE_ACCEPT4 = is_linux or is_freebsd or is_netbsd or is_openbsd,
|
||||
.HAVE_BASENAME = true,
|
||||
.HAVE_CLOSESOCKET = is_windows,
|
||||
.HAVE_DECL_FSEEKO = !is_windows,
|
||||
.HAVE_EVENTFD = is_linux or is_freebsd or is_netbsd,
|
||||
.HAVE_FCNTL = !is_windows,
|
||||
.HAVE_FCNTL_O_NONBLOCK = !is_windows,
|
||||
.HAVE_FNMATCH = !is_windows,
|
||||
.HAVE_FREEADDRINFO = true,
|
||||
.HAVE_FSEEKO = !is_windows,
|
||||
.HAVE_FSETXATTR = is_darwin or is_linux or is_netbsd,
|
||||
.HAVE_FSETXATTR_5 = is_linux or is_netbsd,
|
||||
.HAVE_FSETXATTR_6 = is_darwin,
|
||||
.HAVE_FTRUNCATE = true,
|
||||
.HAVE_GETADDRINFO = true,
|
||||
.HAVE_GETADDRINFO_THREADSAFE = is_linux or is_freebsd or is_netbsd,
|
||||
.HAVE_GETHOSTBYNAME_R = is_linux or is_freebsd,
|
||||
.HAVE_GETHOSTBYNAME_R_3 = false,
|
||||
.HAVE_GETHOSTBYNAME_R_3_REENTRANT = false,
|
||||
.HAVE_GETHOSTBYNAME_R_5 = false,
|
||||
.HAVE_GETHOSTBYNAME_R_5_REENTRANT = false,
|
||||
.HAVE_GETHOSTBYNAME_R_6 = is_linux,
|
||||
.HAVE_GETHOSTBYNAME_R_6_REENTRANT = is_linux,
|
||||
.HAVE_GETHOSTNAME = true,
|
||||
.HAVE_GETIFADDRS = if (is_windows) false else !is_android or target.result.os.versionRange().linux.android >= 24,
|
||||
.HAVE_GETPASS_R = is_netbsd,
|
||||
.HAVE_GETPEERNAME = true,
|
||||
.HAVE_GETPWUID = !is_windows,
|
||||
.HAVE_GETPWUID_R = !is_windows,
|
||||
.HAVE_GETRLIMIT = !is_windows,
|
||||
.HAVE_GETSOCKNAME = true,
|
||||
.HAVE_IF_NAMETOINDEX = !is_windows,
|
||||
.HAVE_INET_NTOP = !is_windows,
|
||||
.HAVE_INET_PTON = !is_windows,
|
||||
.HAVE_IOCTLSOCKET = is_windows,
|
||||
.HAVE_IOCTLSOCKET_CAMEL = false,
|
||||
.HAVE_IOCTLSOCKET_CAMEL_FIONBIO = false,
|
||||
.HAVE_IOCTLSOCKET_FIONBIO = is_windows,
|
||||
.HAVE_IOCTL_FIONBIO = !is_windows,
|
||||
.HAVE_IOCTL_SIOCGIFADDR = !is_windows,
|
||||
.HAVE_MSG_NOSIGNAL = !is_windows,
|
||||
.HAVE_OPENDIR = true,
|
||||
.HAVE_PIPE = !is_windows,
|
||||
.HAVE_PIPE2 = is_linux or is_freebsd or is_netbsd or is_openbsd,
|
||||
.HAVE_POLL = !is_windows,
|
||||
.HAVE_REALPATH = !is_windows,
|
||||
.HAVE_RECV = true,
|
||||
.HAVE_SA_FAMILY_T = !is_windows,
|
||||
.HAVE_SCHED_YIELD = !is_windows,
|
||||
.HAVE_SELECT = true,
|
||||
.HAVE_SEND = true,
|
||||
.HAVE_SENDMMSG = !is_darwin and !is_windows,
|
||||
.HAVE_SENDMSG = !is_windows,
|
||||
.HAVE_SETMODE = !is_linux,
|
||||
.HAVE_SETSOCKOPT_SO_NONBLOCK = false,
|
||||
.HAVE_SOCKADDR_IN6_SIN6_ADDR = !is_windows,
|
||||
.HAVE_SOCKADDR_IN6_SIN6_SCOPE_ID = true,
|
||||
.HAVE_SOCKET = true,
|
||||
.HAVE_SOCKETPAIR = !is_windows,
|
||||
.HAVE_STRUCT_SOCKADDR_STORAGE = true,
|
||||
.HAVE_SUSECONDS_T = is_android or is_ios,
|
||||
.USE_UNIX_SOCKETS = !is_windows,
|
||||
const libs: [4][]const u8 = .{
|
||||
"libdom",
|
||||
"libhubbub",
|
||||
"libparserutils",
|
||||
"libwapcaplet",
|
||||
};
|
||||
|
||||
const curl_config = b.addConfigHeader(.{
|
||||
.include_path = "curl_config.h",
|
||||
.style = .{ .cmake = dep.path("lib/curl_config-cmake.h.in") },
|
||||
}, .{
|
||||
.CURL_EXTERN_SYMBOL = "__attribute__ ((__visibility__ (\"default\"))",
|
||||
});
|
||||
curl_config.addValues(config);
|
||||
|
||||
const lib = b.addLibrary(.{ .name = "curl", .root_module = mod });
|
||||
lib.addConfigHeader(curl_config);
|
||||
lib.installHeadersDirectory(dep.path("include/curl"), "curl", .{});
|
||||
lib.addCSourceFiles(.{
|
||||
.root = dep.path("lib"),
|
||||
.flags = &.{
|
||||
"-D_GNU_SOURCE",
|
||||
"-DHAVE_CONFIG_H",
|
||||
"-DCURL_STATICLIB",
|
||||
"-DBUILDING_LIBCURL",
|
||||
},
|
||||
.files = &.{
|
||||
// You can include all files from lib, libcurl uses #ifdef-guards to exclude code for disabled functions
|
||||
"altsvc.c", "amigaos.c", "asyn-ares.c",
|
||||
"asyn-base.c", "asyn-thrdd.c", "bufq.c",
|
||||
"bufref.c", "cf-h1-proxy.c", "cf-h2-proxy.c",
|
||||
"cf-haproxy.c", "cf-https-connect.c", "cf-ip-happy.c",
|
||||
"cf-socket.c", "cfilters.c", "conncache.c",
|
||||
"connect.c", "content_encoding.c", "cookie.c",
|
||||
"cshutdn.c", "curl_addrinfo.c", "curl_endian.c",
|
||||
"curl_fnmatch.c", "curl_fopen.c", "curl_get_line.c",
|
||||
"curl_gethostname.c", "curl_gssapi.c", "curl_memrchr.c",
|
||||
"curl_ntlm_core.c", "curl_range.c", "curl_rtmp.c",
|
||||
"curl_sasl.c", "curl_sha512_256.c", "curl_share.c",
|
||||
"curl_sspi.c", "curl_threads.c", "curl_trc.c",
|
||||
"curlx/base64.c", "curlx/dynbuf.c", "curlx/fopen.c",
|
||||
"curlx/inet_ntop.c", "curlx/inet_pton.c", "curlx/multibyte.c",
|
||||
"curlx/nonblock.c", "curlx/strcopy.c", "curlx/strerr.c",
|
||||
"curlx/strparse.c", "curlx/timediff.c", "curlx/timeval.c",
|
||||
"curlx/version_win32.c", "curlx/wait.c", "curlx/warnless.c",
|
||||
"curlx/winapi.c", "cw-out.c", "cw-pause.c",
|
||||
"dict.c", "dllmain.c", "doh.c",
|
||||
"dynhds.c", "easy.c", "easygetopt.c",
|
||||
"easyoptions.c", "escape.c", "fake_addrinfo.c",
|
||||
"file.c", "fileinfo.c", "formdata.c",
|
||||
"ftp.c", "ftplistparser.c", "getenv.c",
|
||||
"getinfo.c", "gopher.c", "hash.c",
|
||||
"headers.c", "hmac.c", "hostip.c",
|
||||
"hostip4.c", "hostip6.c", "hsts.c",
|
||||
"http.c", "http1.c", "http2.c",
|
||||
"http_aws_sigv4.c", "http_chunks.c", "http_digest.c",
|
||||
"http_negotiate.c", "http_ntlm.c", "http_proxy.c",
|
||||
"httpsrr.c", "idn.c", "if2ip.c",
|
||||
"imap.c", "ldap.c", "llist.c",
|
||||
"macos.c", "md4.c", "md5.c",
|
||||
"memdebug.c", "mime.c", "mprintf.c",
|
||||
"mqtt.c", "multi.c", "multi_ev.c",
|
||||
"multi_ntfy.c", "netrc.c", "noproxy.c",
|
||||
"openldap.c", "parsedate.c", "pingpong.c",
|
||||
"pop3.c", "progress.c", "psl.c",
|
||||
"rand.c", "ratelimit.c", "request.c",
|
||||
"rtsp.c", "select.c", "sendf.c",
|
||||
"setopt.c", "sha256.c", "slist.c",
|
||||
"smb.c", "smtp.c", "socketpair.c",
|
||||
"socks.c", "socks_gssapi.c", "socks_sspi.c",
|
||||
"splay.c", "strcase.c", "strdup.c",
|
||||
"strequal.c", "strerror.c", "system_win32.c",
|
||||
"telnet.c", "tftp.c", "transfer.c",
|
||||
"uint-bset.c", "uint-hash.c", "uint-spbset.c",
|
||||
"uint-table.c", "url.c", "urlapi.c",
|
||||
"vauth/cleartext.c", "vauth/cram.c", "vauth/digest.c",
|
||||
"vauth/digest_sspi.c", "vauth/gsasl.c", "vauth/krb5_gssapi.c",
|
||||
"vauth/krb5_sspi.c", "vauth/ntlm.c", "vauth/ntlm_sspi.c",
|
||||
"vauth/oauth2.c", "vauth/spnego_gssapi.c", "vauth/spnego_sspi.c",
|
||||
"vauth/vauth.c", "version.c", "vquic/curl_ngtcp2.c",
|
||||
"vquic/curl_osslq.c", "vquic/curl_quiche.c", "vquic/vquic-tls.c",
|
||||
"vquic/vquic.c", "vssh/libssh.c", "vssh/libssh2.c",
|
||||
"vssh/vssh.c", "vtls/apple.c", "vtls/cipher_suite.c",
|
||||
"vtls/gtls.c", "vtls/hostcheck.c", "vtls/keylog.c",
|
||||
"vtls/mbedtls.c", "vtls/openssl.c", "vtls/rustls.c",
|
||||
"vtls/schannel.c", "vtls/schannel_verify.c", "vtls/vtls.c",
|
||||
"vtls/vtls_scache.c", "vtls/vtls_spack.c", "vtls/wolfssl.c",
|
||||
"vtls/x509asn1.c", "ws.c",
|
||||
},
|
||||
});
|
||||
|
||||
return lib;
|
||||
inline for (libs) |lib| {
|
||||
const ns_lib_path = try std.fmt.allocPrint(
|
||||
b.allocator,
|
||||
ns ++ "/out/{s}-{s}/lib/" ++ lib ++ ".a",
|
||||
.{ @tagName(os), @tagName(arch) },
|
||||
);
|
||||
step.addObjectFile(b.path(ns_lib_path));
|
||||
step.addIncludePath(b.path(ns ++ "/" ++ lib ++ "/src"));
|
||||
}
|
||||
|
||||
const Manifest = struct {
|
||||
version: []const u8,
|
||||
minimum_zig_version: []const u8,
|
||||
|
||||
fn init(b: *std.Build) Manifest {
|
||||
const input = @embedFile("build.zig.zon");
|
||||
|
||||
var diagnostics: std.zon.parse.Diagnostics = .{};
|
||||
defer diagnostics.deinit(b.allocator);
|
||||
|
||||
return std.zon.parse.fromSlice(Manifest, b.allocator, input, &diagnostics, .{
|
||||
.free_on_error = true,
|
||||
.ignore_unknown_fields = true,
|
||||
}) catch |err| {
|
||||
switch (err) {
|
||||
error.OutOfMemory => @panic("OOM"),
|
||||
error.ParseZon => {
|
||||
std.debug.print("Parse diagnostics:\n{f}\n", .{diagnostics});
|
||||
std.process.exit(1);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
.{
|
||||
.name = .browser,
|
||||
.paths = .{""},
|
||||
.version = "0.0.0",
|
||||
.fingerprint = 0xda130f3af836cea0, // Changing this has security and trust implications.
|
||||
.minimum_zig_version = "0.15.2",
|
||||
.fingerprint = 0xda130f3af836cea0,
|
||||
.dependencies = .{
|
||||
.tls = .{
|
||||
.url = "https://github.com/ianic/tls.zig/archive/b29a8b45fc59fc2d202769c4f54509bb9e17d0a2.tar.gz",
|
||||
.hash = "tls-0.1.0-ER2e0uAxBQDm_TmSDdbiiyvAZoh4ejlDD4hW8Fl813xE",
|
||||
},
|
||||
.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/refs/tags/v0.3.4.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH6_F3BAAiFvKY6R1H-gkuQlk19BkDQ0--uZuTrSup",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/1d25fcf3ced688adca3c7a95a138771e4ebba692.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH61eyAwDICIkLAkfQcxsX4TMCKY80QiSUgNBQqx-u",
|
||||
},
|
||||
//.v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
// v1.2.0
|
||||
.url = "https://github.com/google/brotli/archive/028fb5a23661f123017c060daa546b55cf4bde29.tar.gz",
|
||||
.hash = "N-V-__8AAJudKgCQCuIiH6MJjAiIJHfg_tT_Ew-0vZwVkCo_",
|
||||
//.tigerbeetle_io = .{ .path = "../tigerbeetle-io" },
|
||||
},
|
||||
.zlib = .{
|
||||
.url = "https://github.com/madler/zlib/releases/download/v1.3.2/zlib-1.3.2.tar.gz",
|
||||
.hash = "N-V-__8AAJ2cNgAgfBtAw33Bxfu1IWISDeKKSr3DAqoAysIJ",
|
||||
},
|
||||
.nghttp2 = .{
|
||||
.url = "https://github.com/nghttp2/nghttp2/releases/download/v1.68.0/nghttp2-1.68.0.tar.gz",
|
||||
.hash = "N-V-__8AAL15vQCI63ZL6Zaz5hJg6JTEgYXGbLnMFSnf7FT3",
|
||||
},
|
||||
.@"boringssl-zig" = .{
|
||||
.url = "git+https://github.com/Syndica/boringssl-zig.git#c53df00d06b02b755ad88bbf4d1202ed9687b096",
|
||||
.hash = "boringssl-0.1.0-VtJeWehMAAA4RNnwRnzEvKcS9rjsR1QVRw1uJrwXxmVK",
|
||||
},
|
||||
.curl = .{
|
||||
.url = "https://github.com/curl/curl/releases/download/curl-8_18_0/curl-8.18.0.tar.gz",
|
||||
.hash = "N-V-__8AALp9QAGn6CCHZ6fK_FfMyGtG824LSHYHHasM3w-y",
|
||||
},
|
||||
},
|
||||
.paths = .{""},
|
||||
}
|
||||
|
||||
166
flake.lock
generated
166
flake.lock
generated
@@ -1,42 +1,5 @@
|
||||
{
|
||||
"nodes": {
|
||||
"fenix": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770708269,
|
||||
"narHash": "sha256-OnZW86app7hHJJoB5lC9GNXY5QBBIESJB+sIdwEyld0=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "6b5325a017a9a9fe7e6252ccac3680cc7181cd63",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1696426674,
|
||||
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "edolstra",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
@@ -55,52 +18,13 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1705309234,
|
||||
"narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"zlsPkg",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1768649915,
|
||||
"narHash": "sha256-jc21hKogFnxU7KXSVTRmxC7u5D4RHwm9BAvDf5/Z1Uo=",
|
||||
"lastModified": 1748964450,
|
||||
"narHash": "sha256-ZouDiXkUk8mkMnah10QcoQ9Nu6UW6AFAHLScS3En6aI=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3e3f3c7f9977dc123c23ee21e8085ed63daf8c37",
|
||||
"rev": "9ff500cd9e123f46c55855eca64beccead29b152",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -112,28 +36,8 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"zigPkgs": "zigPkgs",
|
||||
"zlsPkg": "zlsPkg"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1770668050,
|
||||
"narHash": "sha256-Q05yaIZtQrBKHpyWaPmyJmDRj0lojnVf8nUFE0vydcY=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "9efc1f709f3c8134c3acac5d3592a8e4c184a0c6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "rust-lang",
|
||||
"ref": "nightly",
|
||||
"repo": "rust-analyzer",
|
||||
"type": "github"
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
@@ -150,68 +54,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"zigPkgs": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1770598090,
|
||||
"narHash": "sha256-k+82IDgTd9o5sxHIqGlvfwseKln3Ejx1edGtDltuPXo=",
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"rev": "142495696982c88edddc8e17e4da90d8164acadf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "mitchellh",
|
||||
"repo": "zig-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"zlsPkg": {
|
||||
"inputs": {
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
],
|
||||
"zig-overlay": [
|
||||
"zigPkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1756048867,
|
||||
"narHash": "sha256-GFzSHUljcxy7sM1PaabbkQUdUnLwpherekPWJFxXtnk=",
|
||||
"owner": "zigtools",
|
||||
"repo": "zls",
|
||||
"rev": "ce6c8f02c78e622421cfc2405c67c5222819ec03",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "zigtools",
|
||||
"ref": "0.15.0",
|
||||
"repo": "zls",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
30
flake.nix
30
flake.nix
@@ -3,47 +3,22 @@
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/release-25.05";
|
||||
|
||||
zigPkgs.url = "github:mitchellh/zig-overlay";
|
||||
zigPkgs.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
zlsPkg.url = "github:zigtools/zls/0.15.0";
|
||||
zlsPkg.inputs.zig-overlay.follows = "zigPkgs";
|
||||
zlsPkg.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
fenix = {
|
||||
url = "github:nix-community/fenix";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
zigPkgs,
|
||||
zlsPkg,
|
||||
fenix,
|
||||
flake-utils,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
overlays = [
|
||||
(final: prev: {
|
||||
zigpkgs = zigPkgs.packages.${prev.system};
|
||||
zls = zlsPkg.packages.${prev.system}.default;
|
||||
})
|
||||
];
|
||||
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
inherit system;
|
||||
};
|
||||
|
||||
rustToolchain = fenix.packages.${system}.stable.toolchain;
|
||||
|
||||
# We need crtbeginS.o for building.
|
||||
crtFiles = pkgs.runCommand "crt-files" { } ''
|
||||
mkdir -p $out/lib
|
||||
@@ -57,9 +32,8 @@
|
||||
targetPkgs =
|
||||
pkgs: with pkgs; [
|
||||
# Build Tools
|
||||
zigpkgs."0.15.2"
|
||||
zig
|
||||
zls
|
||||
rustToolchain
|
||||
python3
|
||||
pkg-config
|
||||
cmake
|
||||
|
||||
115
src/App.zig
115
src/App.zig
@@ -1,115 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Config = @import("Config.zig");
|
||||
const Snapshot = @import("browser/js/Snapshot.zig");
|
||||
const Platform = @import("browser/js/Platform.zig");
|
||||
const Telemetry = @import("telemetry/telemetry.zig").Telemetry;
|
||||
|
||||
const Network = @import("network/Runtime.zig");
|
||||
pub const ArenaPool = @import("ArenaPool.zig");
|
||||
|
||||
const App = @This();
|
||||
|
||||
network: Network,
|
||||
config: *const Config,
|
||||
platform: Platform,
|
||||
snapshot: Snapshot,
|
||||
telemetry: Telemetry,
|
||||
allocator: Allocator,
|
||||
arena_pool: ArenaPool,
|
||||
app_dir_path: ?[]const u8,
|
||||
|
||||
pub fn init(allocator: Allocator, config: *const Config) !*App {
|
||||
const app = try allocator.create(App);
|
||||
errdefer allocator.destroy(app);
|
||||
|
||||
app.* = .{
|
||||
.config = config,
|
||||
.allocator = allocator,
|
||||
.network = undefined,
|
||||
.platform = undefined,
|
||||
.snapshot = undefined,
|
||||
.app_dir_path = undefined,
|
||||
.telemetry = undefined,
|
||||
.arena_pool = undefined,
|
||||
};
|
||||
|
||||
app.network = try Network.init(allocator, config);
|
||||
errdefer app.network.deinit();
|
||||
|
||||
app.platform = try Platform.init();
|
||||
errdefer app.platform.deinit();
|
||||
|
||||
app.snapshot = try Snapshot.load();
|
||||
errdefer app.snapshot.deinit();
|
||||
|
||||
app.app_dir_path = getAndMakeAppDir(allocator);
|
||||
|
||||
app.telemetry = try Telemetry.init(app, config.mode);
|
||||
errdefer app.telemetry.deinit(allocator);
|
||||
|
||||
app.arena_pool = ArenaPool.init(allocator, 512, 1024 * 16);
|
||||
errdefer app.arena_pool.deinit();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
pub fn shutdown(self: *const App) bool {
|
||||
return self.network.shutdown.load(.acquire);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *App) void {
|
||||
const allocator = self.allocator;
|
||||
if (self.app_dir_path) |app_dir_path| {
|
||||
allocator.free(app_dir_path);
|
||||
self.app_dir_path = null;
|
||||
}
|
||||
self.telemetry.deinit(allocator);
|
||||
self.network.deinit();
|
||||
self.snapshot.deinit();
|
||||
self.platform.deinit();
|
||||
self.arena_pool.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,212 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const ArenaPool = @This();
|
||||
|
||||
allocator: Allocator,
|
||||
retain_bytes: usize,
|
||||
free_list_len: u16 = 0,
|
||||
free_list: ?*Entry = null,
|
||||
free_list_max: u16,
|
||||
entry_pool: std.heap.MemoryPool(Entry),
|
||||
mutex: std.Thread.Mutex = .{},
|
||||
|
||||
const Entry = struct {
|
||||
next: ?*Entry,
|
||||
arena: ArenaAllocator,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator, free_list_max: u16, retain_bytes: usize) ArenaPool {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.free_list_max = free_list_max,
|
||||
.retain_bytes = retain_bytes,
|
||||
.entry_pool = .init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ArenaPool) void {
|
||||
var entry = self.free_list;
|
||||
while (entry) |e| {
|
||||
entry = e.next;
|
||||
e.arena.deinit();
|
||||
}
|
||||
self.entry_pool.deinit();
|
||||
}
|
||||
|
||||
pub fn acquire(self: *ArenaPool) !Allocator {
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
if (self.free_list) |entry| {
|
||||
self.free_list = entry.next;
|
||||
self.free_list_len -= 1;
|
||||
return entry.arena.allocator();
|
||||
}
|
||||
|
||||
const entry = try self.entry_pool.create();
|
||||
entry.* = .{
|
||||
.next = null,
|
||||
.arena = ArenaAllocator.init(self.allocator),
|
||||
};
|
||||
|
||||
return entry.arena.allocator();
|
||||
}
|
||||
|
||||
pub fn release(self: *ArenaPool, allocator: Allocator) void {
|
||||
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
|
||||
const entry: *Entry = @fieldParentPtr("arena", arena);
|
||||
|
||||
// Reset the arena before acquiring the lock to minimize lock hold time
|
||||
_ = arena.reset(.{ .retain_with_limit = self.retain_bytes });
|
||||
|
||||
self.mutex.lock();
|
||||
defer self.mutex.unlock();
|
||||
|
||||
const free_list_len = self.free_list_len;
|
||||
if (free_list_len == self.free_list_max) {
|
||||
arena.deinit();
|
||||
self.entry_pool.destroy(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.next = self.free_list;
|
||||
self.free_list_len = free_list_len + 1;
|
||||
self.free_list = entry;
|
||||
}
|
||||
|
||||
pub fn reset(_: *const ArenaPool, allocator: Allocator, retain: usize) void {
|
||||
const arena: *std.heap.ArenaAllocator = @ptrCast(@alignCast(allocator.ptr));
|
||||
_ = arena.reset(.{ .retain_with_limit = retain });
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "arena pool - basic acquire and use" {
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const alloc = try pool.acquire();
|
||||
const buf = try alloc.alloc(u8, 64);
|
||||
@memset(buf, 0xAB);
|
||||
try testing.expectEqual(@as(u8, 0xAB), buf[0]);
|
||||
|
||||
pool.release(alloc);
|
||||
}
|
||||
|
||||
test "arena pool - reuse entry after release" {
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const alloc1 = try pool.acquire();
|
||||
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
|
||||
|
||||
pool.release(alloc1);
|
||||
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
|
||||
|
||||
// The same entry should be returned from the free list.
|
||||
const alloc2 = try pool.acquire();
|
||||
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
|
||||
try testing.expectEqual(alloc1.ptr, alloc2.ptr);
|
||||
|
||||
pool.release(alloc2);
|
||||
}
|
||||
|
||||
test "arena pool - multiple concurrent arenas" {
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const a1 = try pool.acquire();
|
||||
const a2 = try pool.acquire();
|
||||
const a3 = try pool.acquire();
|
||||
|
||||
// All three must be distinct arenas.
|
||||
try testing.expect(a1.ptr != a2.ptr);
|
||||
try testing.expect(a2.ptr != a3.ptr);
|
||||
try testing.expect(a1.ptr != a3.ptr);
|
||||
|
||||
_ = try a1.alloc(u8, 16);
|
||||
_ = try a2.alloc(u8, 32);
|
||||
_ = try a3.alloc(u8, 48);
|
||||
|
||||
pool.release(a1);
|
||||
pool.release(a2);
|
||||
pool.release(a3);
|
||||
|
||||
try testing.expectEqual(@as(u16, 3), pool.free_list_len);
|
||||
}
|
||||
|
||||
test "arena pool - free list respects max limit" {
|
||||
// Cap the free list at 1 so the second release discards its arena.
|
||||
var pool = ArenaPool.init(testing.allocator, 1, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const a1 = try pool.acquire();
|
||||
const a2 = try pool.acquire();
|
||||
|
||||
pool.release(a1);
|
||||
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
|
||||
|
||||
// The free list is full; a2's arena should be destroyed, not queued.
|
||||
pool.release(a2);
|
||||
try testing.expectEqual(@as(u16, 1), pool.free_list_len);
|
||||
}
|
||||
|
||||
test "arena pool - reset clears memory without releasing" {
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
defer pool.deinit();
|
||||
|
||||
const alloc = try pool.acquire();
|
||||
|
||||
const buf = try alloc.alloc(u8, 128);
|
||||
@memset(buf, 0xFF);
|
||||
|
||||
// reset() frees arena memory but keeps the allocator in-flight.
|
||||
pool.reset(alloc, 0);
|
||||
|
||||
// The free list must stay empty; the allocator was not released.
|
||||
try testing.expectEqual(@as(u16, 0), pool.free_list_len);
|
||||
|
||||
// Allocating again through the same arena must still work.
|
||||
const buf2 = try alloc.alloc(u8, 64);
|
||||
@memset(buf2, 0x00);
|
||||
try testing.expectEqual(@as(u8, 0x00), buf2[0]);
|
||||
|
||||
pool.release(alloc);
|
||||
}
|
||||
|
||||
test "arena pool - deinit with entries in free list" {
|
||||
// Verifies that deinit properly cleans up free-listed arenas (no leaks
|
||||
// detected by the test allocator).
|
||||
var pool = ArenaPool.init(testing.allocator, 512, 1024 * 16);
|
||||
|
||||
const a1 = try pool.acquire();
|
||||
const a2 = try pool.acquire();
|
||||
_ = try a1.alloc(u8, 256);
|
||||
_ = try a2.alloc(u8, 512);
|
||||
pool.release(a1);
|
||||
pool.release(a2);
|
||||
try testing.expectEqual(@as(u16, 2), pool.free_list_len);
|
||||
|
||||
pool.deinit();
|
||||
}
|
||||
911
src/Config.zig
911
src/Config.zig
@@ -1,911 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const dump = @import("browser/dump.zig");
|
||||
|
||||
const WebBotAuthConfig = @import("network/WebBotAuth.zig").Config;
|
||||
|
||||
pub const RunMode = enum {
|
||||
help,
|
||||
fetch,
|
||||
serve,
|
||||
version,
|
||||
mcp,
|
||||
};
|
||||
|
||||
pub const MAX_LISTENERS = 16;
|
||||
pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096;
|
||||
|
||||
// max message size
|
||||
// +14 for max websocket payload overhead
|
||||
// +140 for the max control packet that might be interleaved in a message
|
||||
pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140;
|
||||
|
||||
mode: Mode,
|
||||
exec_name: []const u8,
|
||||
http_headers: HttpHeaders,
|
||||
|
||||
const Config = @This();
|
||||
|
||||
pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config {
|
||||
var config = Config{
|
||||
.mode = mode,
|
||||
.exec_name = exec_name,
|
||||
.http_headers = undefined,
|
||||
};
|
||||
config.http_headers = try HttpHeaders.init(allocator, &config);
|
||||
return config;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Config, allocator: Allocator) void {
|
||||
self.http_headers.deinit(allocator);
|
||||
}
|
||||
|
||||
pub fn tlsVerifyHost(self: *const Config) bool {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.tls_verify_host,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn obeyRobots(self: *const Config) bool {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.obey_robots,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpProxy(self: *const Config) ?[:0]const u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_proxy,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.proxy_bearer_token,
|
||||
.help, .version => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpMaxConcurrent(self: *const Config) u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_concurrent orelse 10,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpMaxHostOpen(self: *const Config) u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_host_open orelse 4,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpConnectTimeout(self: *const Config) u31 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_connect_timeout orelse 0,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpTimeout(self: *const Config) u31 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_timeout orelse 5000,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn httpMaxRedirects(_: *const Config) u8 {
|
||||
return 10;
|
||||
}
|
||||
|
||||
pub fn httpMaxResponseSize(self: *const Config) ?usize {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.http_max_response_size,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn logLevel(self: *const Config) ?log.Level {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.log_level,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn logFormat(self: *const Config) ?log.Format {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.log_format,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn logFilterScopes(self: *const Config) ?[]const log.Scope {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.log_filter_scopes,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn userAgentSuffix(self: *const Config) ?[]const u8 {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| opts.common.user_agent_suffix,
|
||||
.help, .version => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn cdpTimeout(self: *const Config) usize {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1000,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig {
|
||||
return switch (self.mode) {
|
||||
inline .serve, .fetch, .mcp => |opts| WebBotAuthConfig{
|
||||
.key_file = opts.common.web_bot_auth_key_file orelse return null,
|
||||
.keyid = opts.common.web_bot_auth_keyid orelse return null,
|
||||
.domain = opts.common.web_bot_auth_domain orelse return null,
|
||||
},
|
||||
.help, .version => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn maxConnections(self: *const Config) u16 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.cdp_max_connections,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn maxPendingConnections(self: *const Config) u31 {
|
||||
return switch (self.mode) {
|
||||
.serve => |opts| opts.cdp_max_pending_connections,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
pub const Mode = union(RunMode) {
|
||||
help: bool, // false when being printed because of an error
|
||||
fetch: Fetch,
|
||||
serve: Serve,
|
||||
version: void,
|
||||
mcp: Mcp,
|
||||
};
|
||||
|
||||
pub const Serve = struct {
|
||||
host: []const u8 = "127.0.0.1",
|
||||
port: u16 = 9222,
|
||||
timeout: u31 = 10,
|
||||
cdp_max_connections: u16 = 16,
|
||||
cdp_max_pending_connections: u16 = 128,
|
||||
common: Common = .{},
|
||||
};
|
||||
|
||||
pub const Mcp = struct {
|
||||
common: Common = .{},
|
||||
};
|
||||
|
||||
pub const DumpFormat = enum {
|
||||
html,
|
||||
markdown,
|
||||
wpt,
|
||||
semantic_tree,
|
||||
semantic_tree_text,
|
||||
};
|
||||
|
||||
pub const Fetch = struct {
|
||||
url: [:0]const u8,
|
||||
dump_mode: ?DumpFormat = null,
|
||||
common: Common = .{},
|
||||
with_base: bool = false,
|
||||
with_frames: bool = false,
|
||||
strip: dump.Opts.Strip = .{},
|
||||
};
|
||||
|
||||
pub const Common = struct {
|
||||
obey_robots: bool = false,
|
||||
proxy_bearer_token: ?[:0]const u8 = null,
|
||||
http_proxy: ?[:0]const u8 = null,
|
||||
http_max_concurrent: ?u8 = null,
|
||||
http_max_host_open: ?u8 = null,
|
||||
http_timeout: ?u31 = null,
|
||||
http_connect_timeout: ?u31 = null,
|
||||
http_max_response_size: ?usize = null,
|
||||
tls_verify_host: bool = true,
|
||||
log_level: ?log.Level = null,
|
||||
log_format: ?log.Format = null,
|
||||
log_filter_scopes: ?[]log.Scope = null,
|
||||
user_agent_suffix: ?[]const u8 = null,
|
||||
|
||||
web_bot_auth_key_file: ?[]const u8 = null,
|
||||
web_bot_auth_keyid: ?[]const u8 = null,
|
||||
web_bot_auth_domain: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Pre-formatted HTTP headers for reuse across Http and Client.
|
||||
/// Must be initialized with an allocator that outlives all HTTP connections.
|
||||
pub const HttpHeaders = struct {
|
||||
const user_agent_base: [:0]const u8 = "Lightpanda/1.0";
|
||||
|
||||
user_agent: [:0]const u8, // User agent value (e.g. "Lightpanda/1.0")
|
||||
user_agent_header: [:0]const u8,
|
||||
|
||||
proxy_bearer_header: ?[:0]const u8,
|
||||
|
||||
pub fn init(allocator: Allocator, config: *const Config) !HttpHeaders {
|
||||
const user_agent: [:0]const u8 = if (config.userAgentSuffix()) |suffix|
|
||||
try std.fmt.allocPrintSentinel(allocator, "{s} {s}", .{ user_agent_base, suffix }, 0)
|
||||
else
|
||||
user_agent_base;
|
||||
errdefer if (config.userAgentSuffix() != null) allocator.free(user_agent);
|
||||
|
||||
const user_agent_header = try std.fmt.allocPrintSentinel(allocator, "User-Agent: {s}", .{user_agent}, 0);
|
||||
errdefer allocator.free(user_agent_header);
|
||||
|
||||
const proxy_bearer_header: ?[:0]const u8 = if (config.proxyBearerToken()) |token|
|
||||
try std.fmt.allocPrintSentinel(allocator, "Proxy-Authorization: Bearer {s}", .{token}, 0)
|
||||
else
|
||||
null;
|
||||
|
||||
return .{
|
||||
.user_agent = user_agent,
|
||||
.user_agent_header = user_agent_header,
|
||||
.proxy_bearer_header = proxy_bearer_header,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const HttpHeaders, allocator: Allocator) void {
|
||||
if (self.proxy_bearer_header) |hdr| {
|
||||
allocator.free(hdr);
|
||||
}
|
||||
allocator.free(self.user_agent_header);
|
||||
if (self.user_agent.ptr != user_agent_base.ptr) {
|
||||
allocator.free(self.user_agent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn printUsageAndExit(self: *const Config, success: bool) void {
|
||||
// MAX_HELP_LEN|
|
||||
const common_options =
|
||||
\\
|
||||
\\--insecure_disable_tls_host_verification
|
||||
\\ Disables host verification on all HTTP requests. This is an
|
||||
\\ advanced option which should only be set if you understand
|
||||
\\ and accept the risk of disabling host verification.
|
||||
\\
|
||||
\\--obey_robots
|
||||
\\ Fetches and obeys the robots.txt (if available) of the web pages
|
||||
\\ we make requests towards.
|
||||
\\ Defaults to false.
|
||||
\\
|
||||
\\--http_proxy The HTTP proxy to use for all HTTP requests.
|
||||
\\ A username:password can be included for basic authentication.
|
||||
\\ Defaults to none.
|
||||
\\
|
||||
\\--proxy_bearer_token
|
||||
\\ The <token> to send for bearer authentication with the proxy
|
||||
\\ Proxy-Authorization: Bearer <token>
|
||||
\\
|
||||
\\--http_max_concurrent
|
||||
\\ The maximum number of concurrent HTTP requests.
|
||||
\\ Defaults to 10.
|
||||
\\
|
||||
\\--http_max_host_open
|
||||
\\ The maximum number of open connection to a given host:port.
|
||||
\\ Defaults to 4.
|
||||
\\
|
||||
\\--http_connect_timeout
|
||||
\\ The time, in milliseconds, for establishing an HTTP connection
|
||||
\\ before timing out. 0 means it never times out.
|
||||
\\ Defaults to 0.
|
||||
\\
|
||||
\\--http_timeout
|
||||
\\ The maximum time, in milliseconds, the transfer is allowed
|
||||
\\ to complete. 0 means it never times out.
|
||||
\\ Defaults to 10000.
|
||||
\\
|
||||
\\--http_max_response_size
|
||||
\\ Limits the acceptable response size for any request
|
||||
\\ (e.g. XHR, fetch, script loading, ...).
|
||||
\\ Defaults to no limit.
|
||||
\\
|
||||
\\--log_level The log level: debug, info, warn, error or fatal.
|
||||
\\ Defaults to
|
||||
++ (if (builtin.mode == .Debug) " info." else "warn.") ++
|
||||
\\
|
||||
\\
|
||||
\\--log_format The log format: pretty or logfmt.
|
||||
\\ Defaults to
|
||||
++ (if (builtin.mode == .Debug) " pretty." else " logfmt.") ++
|
||||
\\
|
||||
\\
|
||||
\\--log_filter_scopes
|
||||
\\ Filter out too verbose logs per scope:
|
||||
\\ http, unknown_prop, event, ...
|
||||
\\
|
||||
\\--user_agent_suffix
|
||||
\\ Suffix to append to the Lightpanda/X.Y User-Agent
|
||||
\\
|
||||
\\--web_bot_auth_key_file
|
||||
\\ Path to the Ed25519 private key PEM file.
|
||||
\\
|
||||
\\--web_bot_auth_keyid
|
||||
\\ The JWK thumbprint of your public key.
|
||||
\\
|
||||
\\--web_bot_auth_domain
|
||||
\\ Your domain e.g. yourdomain.com
|
||||
;
|
||||
|
||||
// MAX_HELP_LEN|
|
||||
const usage =
|
||||
\\usage: {s} command [options] [URL]
|
||||
\\
|
||||
\\Command can be either 'fetch', 'serve', 'mcp' or 'help'
|
||||
\\
|
||||
\\fetch command
|
||||
\\Fetches the specified URL
|
||||
\\Example: {s} fetch --dump html https://lightpanda.io/
|
||||
\\
|
||||
\\Options:
|
||||
\\--dump Dumps document to stdout.
|
||||
\\ Argument must be 'html', 'markdown', 'semantic_tree', or 'semantic_tree_text'.
|
||||
\\ Defaults to no dump.
|
||||
\\
|
||||
\\--strip_mode Comma separated list of tag groups to remove from dump
|
||||
\\ the dump. e.g. --strip_mode js,css
|
||||
\\ - "js" script and link[as=script, rel=preload]
|
||||
\\ - "ui" includes img, picture, video, css and svg
|
||||
\\ - "css" includes style and link[rel=stylesheet]
|
||||
\\ - "full" includes js, ui and css
|
||||
\\
|
||||
\\--with_base Add a <base> tag in dump. Defaults to false.
|
||||
\\
|
||||
\\--with_frames Includes the contents of iframes. Defaults to false.
|
||||
\\
|
||||
++ common_options ++
|
||||
\\
|
||||
\\serve command
|
||||
\\Starts a websocket CDP server
|
||||
\\Example: {s} serve --host 127.0.0.1 --port 9222
|
||||
\\
|
||||
\\Options:
|
||||
\\--host Host of the CDP server
|
||||
\\ Defaults to "127.0.0.1"
|
||||
\\
|
||||
\\--port Port of the CDP server
|
||||
\\ Defaults to 9222
|
||||
\\
|
||||
\\--timeout Inactivity timeout in seconds before disconnecting clients
|
||||
\\ Defaults to 10 (seconds). Limited to 604800 (1 week).
|
||||
\\
|
||||
\\--cdp_max_connections
|
||||
\\ Maximum number of simultaneous CDP connections.
|
||||
\\ Defaults to 16.
|
||||
\\
|
||||
\\--cdp_max_pending_connections
|
||||
\\ Maximum pending connections in the accept queue.
|
||||
\\ Defaults to 128.
|
||||
\\
|
||||
++ common_options ++
|
||||
\\
|
||||
\\mcp command
|
||||
\\Starts an MCP (Model Context Protocol) server over stdio
|
||||
\\Example: {s} mcp
|
||||
\\
|
||||
++ common_options ++
|
||||
\\
|
||||
\\version command
|
||||
\\Displays the version of {s}
|
||||
\\
|
||||
\\help command
|
||||
\\Displays this message
|
||||
\\
|
||||
;
|
||||
std.debug.print(usage, .{ self.exec_name, self.exec_name, self.exec_name, self.exec_name, self.exec_name });
|
||||
if (success) {
|
||||
return std.process.cleanExit();
|
||||
}
|
||||
std.process.exit(1);
|
||||
}
|
||||
|
||||
pub fn parseArgs(allocator: Allocator) !Config {
|
||||
var args = try std.process.argsWithAllocator(allocator);
|
||||
defer args.deinit();
|
||||
|
||||
const exec_name = try allocator.dupe(u8, std.fs.path.basename(args.next().?));
|
||||
|
||||
const mode_string = args.next() orelse "";
|
||||
const run_mode = std.meta.stringToEnum(RunMode, mode_string) orelse blk: {
|
||||
const inferred_mode = inferMode(mode_string) orelse
|
||||
return init(allocator, exec_name, .{ .help = false });
|
||||
// "command" wasn't a command but an option. We can't reset args, but
|
||||
// we can create a new one. Not great, but this fallback is temporary
|
||||
// as we transition to this command mode approach.
|
||||
args.deinit();
|
||||
|
||||
args = try std.process.argsWithAllocator(allocator);
|
||||
// skip the exec_name
|
||||
_ = args.skip();
|
||||
|
||||
break :blk inferred_mode;
|
||||
};
|
||||
|
||||
const mode: Mode = switch (run_mode) {
|
||||
.help => .{ .help = true },
|
||||
.serve => .{ .serve = parseServeArgs(allocator, &args) catch
|
||||
return init(allocator, exec_name, .{ .help = false }) },
|
||||
.fetch => .{ .fetch = parseFetchArgs(allocator, &args) catch
|
||||
return init(allocator, exec_name, .{ .help = false }) },
|
||||
.mcp => .{ .mcp = parseMcpArgs(allocator, &args) catch
|
||||
return init(allocator, exec_name, .{ .help = false }) },
|
||||
.version => .{ .version = {} },
|
||||
};
|
||||
return init(allocator, exec_name, mode);
|
||||
}
|
||||
|
||||
fn inferMode(opt: []const u8) ?RunMode {
|
||||
if (opt.len == 0) {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, opt, "--") == false) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--dump")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--noscript")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--strip_mode")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--with_base")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--with_frames")) {
|
||||
return .fetch;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--host")) {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--port")) {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, opt, "--timeout")) {
|
||||
return .serve;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn parseServeArgs(
|
||||
allocator: Allocator,
|
||||
args: *std.process.ArgIterator,
|
||||
) !Serve {
|
||||
var serve: Serve = .{};
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (std.mem.eql(u8, "--host", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--host" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
serve.host = try allocator.dupe(u8, str);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--port", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--port" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.port = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--port", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--timeout" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--timeout", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--cdp_max_connections", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_connections" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.cdp_max_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_connections", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--cdp_max_pending_connections", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--cdp_max_pending_connections" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
serve.cdp_max_pending_connections = std.fmt.parseInt(u16, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--cdp_max_pending_connections", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try parseCommonArg(allocator, opt, args, &serve.common)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.fatal(.app, "unknown argument", .{ .mode = "serve", .arg = opt });
|
||||
return error.UnkownOption;
|
||||
}
|
||||
|
||||
return serve;
|
||||
}
|
||||
|
||||
fn parseMcpArgs(
|
||||
allocator: Allocator,
|
||||
args: *std.process.ArgIterator,
|
||||
) !Mcp {
|
||||
var mcp: Mcp = .{};
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (try parseCommonArg(allocator, opt, args, &mcp.common)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log.fatal(.mcp, "unknown argument", .{ .mode = "mcp", .arg = opt });
|
||||
return error.UnkownOption;
|
||||
}
|
||||
|
||||
return mcp;
|
||||
}
|
||||
|
||||
fn parseFetchArgs(
|
||||
allocator: Allocator,
|
||||
args: *std.process.ArgIterator,
|
||||
) !Fetch {
|
||||
var dump_mode: ?DumpFormat = null;
|
||||
var with_base: bool = false;
|
||||
var with_frames: bool = false;
|
||||
var url: ?[:0]const u8 = null;
|
||||
var common: Common = .{};
|
||||
var strip: dump.Opts.Strip = .{};
|
||||
|
||||
while (args.next()) |opt| {
|
||||
if (std.mem.eql(u8, "--dump", opt)) {
|
||||
var peek_args = args.*;
|
||||
if (peek_args.next()) |next_arg| {
|
||||
if (std.meta.stringToEnum(DumpFormat, next_arg)) |mode| {
|
||||
dump_mode = mode;
|
||||
_ = args.next();
|
||||
} else {
|
||||
dump_mode = .html;
|
||||
}
|
||||
} else {
|
||||
dump_mode = .html;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--noscript", opt)) {
|
||||
log.warn(.app, "deprecation warning", .{
|
||||
.feature = "--noscript argument",
|
||||
.hint = "use '--strip_mode js' instead",
|
||||
});
|
||||
strip.js = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--with_base", opt)) {
|
||||
with_base = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--with_frames", opt)) {
|
||||
with_frames = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--strip_mode", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--strip_mode" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
var it = std.mem.splitScalar(u8, str, ',');
|
||||
while (it.next()) |part| {
|
||||
const trimmed = std.mem.trim(u8, part, &std.ascii.whitespace);
|
||||
if (std.mem.eql(u8, trimmed, "js")) {
|
||||
strip.js = true;
|
||||
} else if (std.mem.eql(u8, trimmed, "ui")) {
|
||||
strip.ui = true;
|
||||
} else if (std.mem.eql(u8, trimmed, "css")) {
|
||||
strip.css = true;
|
||||
} else if (std.mem.eql(u8, trimmed, "full")) {
|
||||
strip.js = true;
|
||||
strip.ui = true;
|
||||
strip.css = true;
|
||||
} else {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--strip_mode", .value = trimmed });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (try parseCommonArg(allocator, opt, args, &common)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, opt, "--")) {
|
||||
log.fatal(.app, "unknown argument", .{ .mode = "fetch", .arg = opt });
|
||||
return error.UnkownOption;
|
||||
}
|
||||
|
||||
if (url != null) {
|
||||
log.fatal(.app, "duplicate fetch url", .{ .help = "only 1 URL can be specified" });
|
||||
return error.TooManyURLs;
|
||||
}
|
||||
url = try allocator.dupeZ(u8, opt);
|
||||
}
|
||||
|
||||
if (url == null) {
|
||||
log.fatal(.app, "missing fetch url", .{ .help = "URL to fetch must be provided" });
|
||||
return error.MissingURL;
|
||||
}
|
||||
|
||||
return .{
|
||||
.url = url.?,
|
||||
.dump_mode = dump_mode,
|
||||
.strip = strip,
|
||||
.common = common,
|
||||
.with_base = with_base,
|
||||
.with_frames = with_frames,
|
||||
};
|
||||
}
|
||||
|
||||
fn parseCommonArg(
|
||||
allocator: Allocator,
|
||||
opt: []const u8,
|
||||
args: *std.process.ArgIterator,
|
||||
common: *Common,
|
||||
) !bool {
|
||||
if (std.mem.eql(u8, "--insecure_disable_tls_host_verification", opt)) {
|
||||
common.tls_verify_host = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--obey_robots", opt)) {
|
||||
common.obey_robots = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_proxy", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_proxy" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.http_proxy = try allocator.dupeZ(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--proxy_bearer_token", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--proxy_bearer_token" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.proxy_bearer_token = try allocator.dupeZ(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_concurrent", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_concurrent" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_concurrent = std.fmt.parseInt(u8, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_concurrent", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_host_open", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_host_open" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_host_open = std.fmt.parseInt(u8, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_host_open", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_connect_timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_connect_timeout" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_connect_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_connect_timeout", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_timeout", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_timeout" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_timeout = std.fmt.parseInt(u31, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_timeout", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--http_max_response_size", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--http_max_response_size" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.http_max_response_size = std.fmt.parseInt(usize, str, 10) catch |err| {
|
||||
log.fatal(.app, "invalid argument value", .{ .arg = "--http_max_response_size", .err = err });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_level", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_level" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.log_level = std.meta.stringToEnum(log.Level, str) orelse blk: {
|
||||
if (std.mem.eql(u8, str, "error")) {
|
||||
break :blk .err;
|
||||
}
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_level", .value = str });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_format", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--log_format" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
|
||||
common.log_format = std.meta.stringToEnum(log.Format, str) orelse {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_format", .value = str });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--log_filter_scopes", opt)) {
|
||||
if (builtin.mode != .Debug) {
|
||||
log.fatal(.app, "experimental", .{ .help = "log scope filtering is only available in debug builds" });
|
||||
return false;
|
||||
}
|
||||
|
||||
const str = args.next() orelse {
|
||||
// disables the default filters
|
||||
common.log_filter_scopes = &.{};
|
||||
return true;
|
||||
};
|
||||
|
||||
var arr: std.ArrayList(log.Scope) = .empty;
|
||||
|
||||
var it = std.mem.splitScalar(u8, str, ',');
|
||||
while (it.next()) |part| {
|
||||
try arr.append(allocator, std.meta.stringToEnum(log.Scope, part) orelse {
|
||||
log.fatal(.app, "invalid option choice", .{ .arg = "--log_filter_scopes", .value = part });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
common.log_filter_scopes = arr.items;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--user_agent_suffix", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--user_agent_suffix" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
for (str) |c| {
|
||||
if (!std.ascii.isPrint(c)) {
|
||||
log.fatal(.app, "not printable character", .{ .arg = "--user_agent_suffix" });
|
||||
return error.InvalidArgument;
|
||||
}
|
||||
}
|
||||
common.user_agent_suffix = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--web_bot_auth_key_file", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_key_file" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.web_bot_auth_key_file = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--web_bot_auth_keyid", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_keyid" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.web_bot_auth_keyid = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, "--web_bot_auth_domain", opt)) {
|
||||
const str = args.next() orelse {
|
||||
log.fatal(.app, "missing argument value", .{ .arg = "--web_bot_auth_domain" });
|
||||
return error.InvalidArgument;
|
||||
};
|
||||
common.web_bot_auth_domain = try allocator.dupe(u8, str);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,419 +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 lp = @import("lightpanda");
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Page = @import("browser/Page.zig");
|
||||
const Transfer = @import("browser/HttpClient.zig").Transfer;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const List = std.DoublyLinkedList;
|
||||
|
||||
// Allows code to register for and emit events.
|
||||
// Keeps two lists
|
||||
// 1 - for a given event type, a linked list of all the listeners
|
||||
// 2 - for a given listener, a list of all it's registration
|
||||
// The 2nd one is so that a listener can unregister all of it's listeners
|
||||
// (there's currently no need for a listener to unregister only 1 or more
|
||||
// specific listener).
|
||||
//
|
||||
// Scoping is important. Imagine we created a global singleton registry, and our
|
||||
// CDP code registers for the "network_bytes_sent" event, because it needs to
|
||||
// send messages to the client when this happens. Our HTTP client could then
|
||||
// emit a "network_bytes_sent" message. It would be easy, and it would work.
|
||||
// That is, it would work until multiple CDP clients connect, and because
|
||||
// everything's just one big global, events from one CDP session would be sent
|
||||
// to all CDP clients.
|
||||
//
|
||||
// To avoid this, one way or another, we need scoping. We could still have
|
||||
// a global registry but every "register" and every "emit" has some type of
|
||||
// "scope". This would have a run-time cost and still require some coordination
|
||||
// between components to share a common scope.
|
||||
//
|
||||
// Instead, the approach that we take is to have a notification instance per
|
||||
// CDP connection (BrowserContext). Each CDP connection has its own notification
|
||||
// that is shared across all Sessions (tabs) within that connection. This ensures
|
||||
// proper isolation between different CDP clients while allowing a single client
|
||||
// to receive events from all its tabs.
|
||||
const Notification = @This();
|
||||
// Every event type (which are hard-coded), has a list of Listeners.
|
||||
// When the event happens, we dispatch to those listener.
|
||||
event_listeners: EventListeners,
|
||||
|
||||
// list of listeners for a specified receiver
|
||||
// @intFromPtr(receiver) -> [listener1, listener2, ...]
|
||||
// Used when `unregisterAll` is called.
|
||||
listeners: std.AutoHashMapUnmanaged(usize, std.ArrayList(*Listener)),
|
||||
|
||||
allocator: Allocator,
|
||||
mem_pool: std.heap.MemoryPool(Listener),
|
||||
|
||||
const EventListeners = struct {
|
||||
page_remove: List = .{},
|
||||
page_created: List = .{},
|
||||
page_navigate: List = .{},
|
||||
page_navigated: List = .{},
|
||||
page_network_idle: List = .{},
|
||||
page_network_almost_idle: List = .{},
|
||||
page_frame_created: List = .{},
|
||||
http_request_fail: List = .{},
|
||||
http_request_start: List = .{},
|
||||
http_request_intercept: List = .{},
|
||||
http_request_done: List = .{},
|
||||
http_request_auth_required: List = .{},
|
||||
http_response_data: List = .{},
|
||||
http_response_header_done: List = .{},
|
||||
};
|
||||
|
||||
const Events = union(enum) {
|
||||
page_remove: PageRemove,
|
||||
page_created: *Page,
|
||||
page_navigate: *const PageNavigate,
|
||||
page_navigated: *const PageNavigated,
|
||||
page_network_idle: *const PageNetworkIdle,
|
||||
page_network_almost_idle: *const PageNetworkAlmostIdle,
|
||||
page_frame_created: *const PageFrameCreated,
|
||||
http_request_fail: *const RequestFail,
|
||||
http_request_start: *const RequestStart,
|
||||
http_request_intercept: *const RequestIntercept,
|
||||
http_request_auth_required: *const RequestAuthRequired,
|
||||
http_request_done: *const RequestDone,
|
||||
http_response_data: *const ResponseData,
|
||||
http_response_header_done: *const ResponseHeaderDone,
|
||||
};
|
||||
const EventType = std.meta.FieldEnum(Events);
|
||||
|
||||
pub const PageRemove = struct {};
|
||||
|
||||
pub const PageNavigate = struct {
|
||||
req_id: u32,
|
||||
frame_id: u32,
|
||||
timestamp: u64,
|
||||
url: [:0]const u8,
|
||||
opts: Page.NavigateOpts,
|
||||
};
|
||||
|
||||
pub const PageNavigated = struct {
|
||||
req_id: u32,
|
||||
frame_id: u32,
|
||||
timestamp: u64,
|
||||
url: [:0]const u8,
|
||||
opts: Page.NavigatedOpts,
|
||||
};
|
||||
|
||||
pub const PageNetworkIdle = struct {
|
||||
req_id: u32,
|
||||
frame_id: u32,
|
||||
timestamp: u64,
|
||||
};
|
||||
|
||||
pub const PageNetworkAlmostIdle = struct {
|
||||
req_id: u32,
|
||||
frame_id: u32,
|
||||
timestamp: u64,
|
||||
};
|
||||
|
||||
pub const PageFrameCreated = struct {
|
||||
frame_id: u32,
|
||||
parent_id: u32,
|
||||
timestamp: u64,
|
||||
};
|
||||
|
||||
pub const RequestStart = struct {
|
||||
transfer: *Transfer,
|
||||
};
|
||||
|
||||
pub const RequestIntercept = struct {
|
||||
transfer: *Transfer,
|
||||
wait_for_interception: *bool,
|
||||
};
|
||||
|
||||
pub const RequestAuthRequired = struct {
|
||||
transfer: *Transfer,
|
||||
wait_for_interception: *bool,
|
||||
};
|
||||
|
||||
pub const ResponseData = struct {
|
||||
data: []const u8,
|
||||
transfer: *Transfer,
|
||||
};
|
||||
|
||||
pub const ResponseHeaderDone = struct {
|
||||
transfer: *Transfer,
|
||||
};
|
||||
|
||||
pub const RequestDone = struct {
|
||||
transfer: *Transfer,
|
||||
};
|
||||
|
||||
pub const RequestFail = struct {
|
||||
transfer: *Transfer,
|
||||
err: anyerror,
|
||||
};
|
||||
|
||||
pub fn init(allocator: Allocator) !*Notification {
|
||||
const notification = try allocator.create(Notification);
|
||||
errdefer allocator.destroy(notification);
|
||||
|
||||
notification.* = .{
|
||||
.listeners = .{},
|
||||
.event_listeners = .{},
|
||||
.allocator = allocator,
|
||||
.mem_pool = std.heap.MemoryPool(Listener).init(allocator),
|
||||
};
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Notification) void {
|
||||
const allocator = self.allocator;
|
||||
|
||||
var it = self.listeners.valueIterator();
|
||||
while (it.next()) |listener| {
|
||||
listener.deinit(allocator);
|
||||
}
|
||||
self.listeners.deinit(allocator);
|
||||
self.mem_pool.deinit();
|
||||
allocator.destroy(self);
|
||||
}
|
||||
|
||||
pub fn register(self: *Notification, comptime event: EventType, receiver: anytype, func: EventFunc(event)) !void {
|
||||
var list = &@field(self.event_listeners, @tagName(event));
|
||||
|
||||
var listener = try self.mem_pool.create();
|
||||
errdefer self.mem_pool.destroy(listener);
|
||||
|
||||
listener.* = .{
|
||||
.node = .{},
|
||||
.list = list,
|
||||
.receiver = receiver,
|
||||
.event = event,
|
||||
.func = @ptrCast(func),
|
||||
.struct_name = @typeName(@typeInfo(@TypeOf(receiver)).pointer.child),
|
||||
};
|
||||
|
||||
const allocator = self.allocator;
|
||||
const gop = try self.listeners.getOrPut(allocator, @intFromPtr(receiver));
|
||||
if (gop.found_existing == false) {
|
||||
gop.value_ptr.* = .{};
|
||||
}
|
||||
try gop.value_ptr.append(allocator, listener);
|
||||
|
||||
// we don't add this until we've successfully added the entry to
|
||||
// self.listeners
|
||||
list.append(&listener.node);
|
||||
}
|
||||
|
||||
pub fn unregister(self: *Notification, comptime event: EventType, receiver: anytype) void {
|
||||
var listeners = self.listeners.getPtr(@intFromPtr(receiver)) orelse return;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < listeners.items.len) {
|
||||
const listener = listeners.items[i];
|
||||
if (listener.event != event) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
listener.list.remove(&listener.node);
|
||||
self.mem_pool.destroy(listener);
|
||||
_ = listeners.swapRemove(i);
|
||||
}
|
||||
|
||||
if (listeners.items.len == 0) {
|
||||
listeners.deinit(self.allocator);
|
||||
const removed = self.listeners.remove(@intFromPtr(receiver));
|
||||
lp.assert(removed == true, "Notification.unregister", .{ .type = event });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unregisterAll(self: *Notification, receiver: *anyopaque) void {
|
||||
var kv = self.listeners.fetchRemove(@intFromPtr(receiver)) orelse return;
|
||||
for (kv.value.items) |listener| {
|
||||
listener.list.remove(&listener.node);
|
||||
self.mem_pool.destroy(listener);
|
||||
}
|
||||
kv.value.deinit(self.allocator);
|
||||
}
|
||||
|
||||
pub fn dispatch(self: *Notification, comptime event: EventType, data: ArgType(event)) void {
|
||||
if (self.listeners.count() == 0) {
|
||||
return;
|
||||
}
|
||||
const list = &@field(self.event_listeners, @tagName(event));
|
||||
|
||||
var node = list.first;
|
||||
while (node) |n| {
|
||||
const listener: *Listener = @fieldParentPtr("node", n);
|
||||
const func: EventFunc(event) = @ptrCast(@alignCast(listener.func));
|
||||
func(listener.receiver, data) catch |err| {
|
||||
log.err(.app, "dispatch error", .{
|
||||
.err = err,
|
||||
.event = event,
|
||||
.source = "notification",
|
||||
.listener = listener.struct_name,
|
||||
});
|
||||
};
|
||||
node = n.next;
|
||||
}
|
||||
}
|
||||
|
||||
// Given an event type enum, returns the type of arg the event emits
|
||||
fn ArgType(comptime event: Notification.EventType) type {
|
||||
inline for (std.meta.fields(Notification.Events)) |f| {
|
||||
if (std.mem.eql(u8, f.name, @tagName(event))) {
|
||||
return f.type;
|
||||
}
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
// Given an event type enum, returns the listening function type
|
||||
fn EventFunc(comptime event: Notification.EventType) type {
|
||||
return *const fn (*anyopaque, ArgType(event)) anyerror!void;
|
||||
}
|
||||
|
||||
// A listener. This is 1 receiver, with its function, and the linked list
|
||||
// node that goes in the appropriate EventListeners list.
|
||||
const Listener = struct {
|
||||
// the receiver of the event, i.e. the self parameter to `func`
|
||||
receiver: *anyopaque,
|
||||
|
||||
// the function to call
|
||||
func: *const anyopaque,
|
||||
|
||||
// For logging slightly better error
|
||||
struct_name: []const u8,
|
||||
|
||||
event: Notification.EventType,
|
||||
|
||||
// intrusive linked list node
|
||||
node: List.Node,
|
||||
|
||||
// The event list this listener belongs to.
|
||||
// We need this in order to be able to remove the node from the list
|
||||
list: *List,
|
||||
};
|
||||
|
||||
const testing = std.testing;
|
||||
test "Notification" {
|
||||
var notifier = try Notification.init(testing.allocator);
|
||||
defer notifier.deinit();
|
||||
|
||||
// noop
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.frame_id = 0,
|
||||
.req_id = 1,
|
||||
.timestamp = 4,
|
||||
.url = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
|
||||
var tc = TestClient{};
|
||||
|
||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.frame_id = 0,
|
||||
.req_id = 1,
|
||||
.timestamp = 4,
|
||||
.url = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
try testing.expectEqual(4, tc.page_navigate);
|
||||
|
||||
notifier.unregisterAll(&tc);
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.frame_id = 0,
|
||||
.req_id = 1,
|
||||
.timestamp = 10,
|
||||
.url = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
try testing.expectEqual(4, tc.page_navigate);
|
||||
|
||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.frame_id = 0,
|
||||
.req_id = 1,
|
||||
.timestamp = 10,
|
||||
.url = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 6, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(14, tc.page_navigate);
|
||||
try testing.expectEqual(6, tc.page_navigated);
|
||||
|
||||
notifier.unregisterAll(&tc);
|
||||
notifier.dispatch(.page_navigate, &.{
|
||||
.frame_id = 0,
|
||||
.req_id = 1,
|
||||
.timestamp = 100,
|
||||
.url = undefined,
|
||||
.opts = .{},
|
||||
});
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(14, tc.page_navigate);
|
||||
try testing.expectEqual(6, tc.page_navigated);
|
||||
|
||||
{
|
||||
// unregister
|
||||
try notifier.register(.page_navigate, &tc, TestClient.pageNavigate);
|
||||
try notifier.register(.page_navigated, &tc, TestClient.pageNavigated);
|
||||
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(114, tc.page_navigate);
|
||||
try testing.expectEqual(1006, tc.page_navigated);
|
||||
|
||||
notifier.unregister(.page_navigate, &tc);
|
||||
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(114, tc.page_navigate);
|
||||
try testing.expectEqual(2006, tc.page_navigated);
|
||||
|
||||
notifier.unregister(.page_navigated, &tc);
|
||||
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(114, tc.page_navigate);
|
||||
try testing.expectEqual(2006, tc.page_navigated);
|
||||
|
||||
// already unregistered, try anyways
|
||||
notifier.unregister(.page_navigated, &tc);
|
||||
notifier.dispatch(.page_navigate, &.{ .frame_id = 0, .req_id = 1, .timestamp = 100, .url = undefined, .opts = .{} });
|
||||
notifier.dispatch(.page_navigated, &.{ .frame_id = 0, .req_id = 1, .timestamp = 1000, .url = undefined, .opts = .{} });
|
||||
try testing.expectEqual(114, tc.page_navigate);
|
||||
try testing.expectEqual(2006, tc.page_navigated);
|
||||
}
|
||||
}
|
||||
|
||||
const TestClient = struct {
|
||||
page_navigate: u64 = 0,
|
||||
page_navigated: u64 = 0,
|
||||
|
||||
fn pageNavigate(ptr: *anyopaque, data: *const Notification.PageNavigate) !void {
|
||||
const self: *TestClient = @ptrCast(@alignCast(ptr));
|
||||
self.page_navigate += data.timestamp;
|
||||
}
|
||||
|
||||
fn pageNavigated(ptr: *anyopaque, data: *const Notification.PageNavigated) !void {
|
||||
const self: *TestClient = @ptrCast(@alignCast(ptr));
|
||||
self.page_navigated += data.timestamp;
|
||||
}
|
||||
};
|
||||
@@ -1,532 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. See <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const lp = @import("lightpanda");
|
||||
const log = @import("log.zig");
|
||||
const isAllWhitespace = @import("string.zig").isAllWhitespace;
|
||||
const Page = lp.Page;
|
||||
const interactive = @import("browser/interactive.zig");
|
||||
|
||||
const CData = @import("browser/webapi/CData.zig");
|
||||
const Element = @import("browser/webapi/Element.zig");
|
||||
const Node = @import("browser/webapi/Node.zig");
|
||||
const AXNode = @import("cdp/AXNode.zig");
|
||||
const CDPNode = @import("cdp/Node.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
dom_node: *Node,
|
||||
registry: *CDPNode.Registry,
|
||||
page: *Page,
|
||||
arena: std.mem.Allocator,
|
||||
prune: bool = true,
|
||||
interactive_only: bool = false,
|
||||
max_depth: u32 = std.math.maxInt(u32) - 1,
|
||||
|
||||
pub fn jsonStringify(self: @This(), jw: *std.json.Stringify) error{WriteFailed}!void {
|
||||
var visitor = JsonVisitor{ .jw = jw, .tree = self };
|
||||
var xpath_buffer: std.ArrayList(u8) = .{};
|
||||
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
|
||||
log.err(.app, "listener map failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
|
||||
log.err(.app, "semantic tree json dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn textStringify(self: @This(), writer: *std.Io.Writer) error{WriteFailed}!void {
|
||||
var visitor = TextVisitor{ .writer = writer, .tree = self, .depth = 0 };
|
||||
var xpath_buffer: std.ArrayList(u8) = .empty;
|
||||
const listener_targets = interactive.buildListenerTargetMap(self.page, self.arena) catch |err| {
|
||||
log.err(.app, "listener map failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
self.walk(self.dom_node, &xpath_buffer, null, &visitor, 1, listener_targets, 0) catch |err| {
|
||||
log.err(.app, "semantic tree text dump failed", .{ .err = err });
|
||||
return error.WriteFailed;
|
||||
};
|
||||
}
|
||||
|
||||
const OptionData = struct {
|
||||
value: []const u8,
|
||||
text: []const u8,
|
||||
selected: bool,
|
||||
};
|
||||
|
||||
const NodeData = struct {
|
||||
id: CDPNode.Id,
|
||||
axn: AXNode,
|
||||
role: []const u8,
|
||||
name: ?[]const u8,
|
||||
value: ?[]const u8,
|
||||
options: ?[]OptionData = null,
|
||||
xpath: []const u8,
|
||||
is_interactive: bool,
|
||||
node_name: []const u8,
|
||||
};
|
||||
|
||||
fn walk(self: @This(), node: *Node, xpath_buffer: *std.ArrayList(u8), parent_name: ?[]const u8, visitor: anytype, index: usize, listener_targets: interactive.ListenerTargetMap, current_depth: u32) !void {
|
||||
if (current_depth > self.max_depth) return;
|
||||
|
||||
// 1. Skip non-content nodes
|
||||
if (node.is(Element)) |el| {
|
||||
const tag = el.getTag();
|
||||
if (tag.isMetadata() or tag == .svg) return;
|
||||
|
||||
// We handle options/optgroups natively inside their parents, skip them in the general walk
|
||||
if (tag == .datalist or tag == .option or tag == .optgroup) return;
|
||||
|
||||
// Check visibility using the engine's checkVisibility which handles CSS display: none
|
||||
if (!el.checkVisibility(self.page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (el.is(Element.Html)) |html_el| {
|
||||
if (html_el.getHidden()) return;
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
const text = text_node.getWholeText();
|
||||
if (isAllWhitespace(text)) {
|
||||
return;
|
||||
}
|
||||
} else if (node._type != .document and node._type != .document_fragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cdp_node = try self.registry.register(node);
|
||||
const axn = AXNode.fromNode(node);
|
||||
const role = try axn.getRole();
|
||||
|
||||
var is_interactive = false;
|
||||
var value: ?[]const u8 = null;
|
||||
var options: ?[]OptionData = null;
|
||||
var node_name: []const u8 = "text";
|
||||
|
||||
if (node.is(Element)) |el| {
|
||||
node_name = el.getTagNameLower();
|
||||
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
value = input.getValue();
|
||||
if (el.getAttributeSafe(comptime lp.String.wrap("list"))) |list_id| {
|
||||
options = try extractDataListOptions(list_id, self.page, self.arena);
|
||||
}
|
||||
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
||||
value = textarea.getValue();
|
||||
} else if (el.is(Element.Html.Select)) |select| {
|
||||
value = select.getValue(self.page);
|
||||
options = try extractSelectOptions(el.asNode(), self.page, self.arena);
|
||||
}
|
||||
|
||||
if (el.is(Element.Html)) |html_el| {
|
||||
if (interactive.classifyInteractivity(el, html_el, listener_targets) != null) {
|
||||
is_interactive = true;
|
||||
}
|
||||
}
|
||||
} else if (node._type == .document or node._type == .document_fragment) {
|
||||
node_name = "root";
|
||||
}
|
||||
|
||||
const initial_xpath_len = xpath_buffer.items.len;
|
||||
try appendXPathSegment(node, xpath_buffer.writer(self.arena), index);
|
||||
const xpath = xpath_buffer.items;
|
||||
|
||||
var name = try axn.getName(self.page, self.arena);
|
||||
|
||||
const has_explicit_label = if (node.is(Element)) |el|
|
||||
el.getAttributeSafe(.wrap("aria-label")) != null or el.getAttributeSafe(.wrap("title")) != null
|
||||
else
|
||||
false;
|
||||
|
||||
const structural = isStructuralRole(role);
|
||||
|
||||
// Filter out computed concatenated names for generic containers without explicit labels.
|
||||
// This prevents token bloat and ensures their StaticText children aren't incorrectly pruned.
|
||||
// We ignore interactivity because a generic wrapper with an event listener still shouldn't hoist all text.
|
||||
if (name != null and structural and !has_explicit_label) {
|
||||
name = null;
|
||||
}
|
||||
|
||||
var data = NodeData{
|
||||
.id = cdp_node.id,
|
||||
.axn = axn,
|
||||
.role = role,
|
||||
.name = name,
|
||||
.value = value,
|
||||
.options = options,
|
||||
.xpath = xpath,
|
||||
.is_interactive = is_interactive,
|
||||
.node_name = node_name,
|
||||
};
|
||||
|
||||
var should_visit = true;
|
||||
if (self.interactive_only) {
|
||||
var keep = false;
|
||||
if (interactive.isInteractiveRole(role)) {
|
||||
keep = true;
|
||||
} else if (interactive.isContentRole(role)) {
|
||||
if (name != null and name.?.len > 0) {
|
||||
keep = true;
|
||||
}
|
||||
} else if (std.mem.eql(u8, role, "RootWebArea")) {
|
||||
keep = true;
|
||||
} else if (is_interactive) {
|
||||
keep = true;
|
||||
}
|
||||
if (!keep) {
|
||||
should_visit = false;
|
||||
}
|
||||
} else if (self.prune) {
|
||||
if (structural and !is_interactive and !has_explicit_label) {
|
||||
should_visit = false;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, role, "StaticText") and node._parent != null) {
|
||||
if (parent_name != null and name != null and std.mem.indexOf(u8, parent_name.?, name.?) != null) {
|
||||
should_visit = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var did_visit = false;
|
||||
var should_walk_children = true;
|
||||
if (should_visit) {
|
||||
should_walk_children = try visitor.visit(node, &data);
|
||||
did_visit = true; // Always true if should_visit was true, because visit() executed and opened structures
|
||||
} else {
|
||||
// If we skip the node, we must NOT tell the visitor to close it later
|
||||
did_visit = false;
|
||||
}
|
||||
|
||||
if (should_walk_children) {
|
||||
// If we are printing this node normally OR skipping it and unrolling its children,
|
||||
// we walk the children iterator.
|
||||
var it = node.childrenIterator();
|
||||
var tag_counts = std.StringArrayHashMap(usize).init(self.arena);
|
||||
while (it.next()) |child| {
|
||||
var tag: []const u8 = "text()";
|
||||
if (child.is(Element)) |el| {
|
||||
tag = el.getTagNameLower();
|
||||
}
|
||||
|
||||
const gop = try tag_counts.getOrPut(tag);
|
||||
if (!gop.found_existing) {
|
||||
gop.value_ptr.* = 0;
|
||||
}
|
||||
gop.value_ptr.* += 1;
|
||||
|
||||
try self.walk(child, xpath_buffer, name, visitor, gop.value_ptr.*, listener_targets, current_depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (did_visit) {
|
||||
try visitor.leave();
|
||||
}
|
||||
|
||||
xpath_buffer.shrinkRetainingCapacity(initial_xpath_len);
|
||||
}
|
||||
|
||||
fn extractSelectOptions(node: *Node, page: *Page, arena: std.mem.Allocator) ![]OptionData {
|
||||
var options = std.ArrayListUnmanaged(OptionData){};
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
if (child.is(Element)) |el| {
|
||||
if (el.getTag() == .option) {
|
||||
if (el.is(Element.Html.Option)) |opt| {
|
||||
const text = opt.getText(page);
|
||||
const value = opt.getValue(page);
|
||||
const selected = opt.getSelected();
|
||||
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||
}
|
||||
} else if (el.getTag() == .optgroup) {
|
||||
var group_it = child.childrenIterator();
|
||||
while (group_it.next()) |group_child| {
|
||||
if (group_child.is(Element.Html.Option)) |opt| {
|
||||
const text = opt.getText(page);
|
||||
const value = opt.getValue(page);
|
||||
const selected = opt.getSelected();
|
||||
try options.append(arena, .{ .text = text, .value = value, .selected = selected });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return options.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
fn extractDataListOptions(list_id: []const u8, page: *Page, arena: std.mem.Allocator) !?[]OptionData {
|
||||
if (page.document.getElementById(list_id, page)) |referenced_el| {
|
||||
if (referenced_el.getTag() == .datalist) {
|
||||
return try extractSelectOptions(referenced_el.asNode(), page, arena);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn appendXPathSegment(node: *Node, writer: anytype, index: usize) !void {
|
||||
if (node.is(Element)) |el| {
|
||||
const tag = el.getTagNameLower();
|
||||
try std.fmt.format(writer, "/{s}[{d}]", .{ tag, index });
|
||||
} else if (node.is(CData.Text)) |_| {
|
||||
try std.fmt.format(writer, "/text()[{d}]", .{index});
|
||||
}
|
||||
}
|
||||
|
||||
const JsonVisitor = struct {
|
||||
jw: *std.json.Stringify,
|
||||
tree: Self,
|
||||
|
||||
pub fn visit(self: *JsonVisitor, node: *Node, data: *NodeData) !bool {
|
||||
try self.jw.beginObject();
|
||||
|
||||
try self.jw.objectField("nodeId");
|
||||
try self.jw.write(try std.fmt.allocPrint(self.tree.arena, "{d}", .{data.id}));
|
||||
|
||||
try self.jw.objectField("backendDOMNodeId");
|
||||
try self.jw.write(data.id);
|
||||
|
||||
try self.jw.objectField("nodeName");
|
||||
try self.jw.write(data.node_name);
|
||||
|
||||
try self.jw.objectField("xpath");
|
||||
try self.jw.write(data.xpath);
|
||||
|
||||
if (node.is(Element)) |el| {
|
||||
try self.jw.objectField("nodeType");
|
||||
try self.jw.write(1);
|
||||
|
||||
try self.jw.objectField("isInteractive");
|
||||
try self.jw.write(data.is_interactive);
|
||||
|
||||
try self.jw.objectField("role");
|
||||
try self.jw.write(data.role);
|
||||
|
||||
if (data.name) |name| {
|
||||
if (name.len > 0) {
|
||||
try self.jw.objectField("name");
|
||||
try self.jw.write(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.value) |value| {
|
||||
try self.jw.objectField("value");
|
||||
try self.jw.write(value);
|
||||
}
|
||||
|
||||
if (el._attributes) |attrs| {
|
||||
try self.jw.objectField("attributes");
|
||||
try self.jw.beginObject();
|
||||
var iter = attrs.iterator();
|
||||
while (iter.next()) |attr| {
|
||||
try self.jw.objectField(attr._name.str());
|
||||
try self.jw.write(attr._value.str());
|
||||
}
|
||||
try self.jw.endObject();
|
||||
}
|
||||
|
||||
if (data.options) |options| {
|
||||
try self.jw.objectField("options");
|
||||
try self.jw.beginArray();
|
||||
for (options) |opt| {
|
||||
try self.jw.beginObject();
|
||||
try self.jw.objectField("value");
|
||||
try self.jw.write(opt.value);
|
||||
try self.jw.objectField("text");
|
||||
try self.jw.write(opt.text);
|
||||
try self.jw.objectField("selected");
|
||||
try self.jw.write(opt.selected);
|
||||
try self.jw.endObject();
|
||||
}
|
||||
try self.jw.endArray();
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
try self.jw.objectField("nodeType");
|
||||
try self.jw.write(3);
|
||||
try self.jw.objectField("nodeValue");
|
||||
try self.jw.write(text_node.getWholeText());
|
||||
} else {
|
||||
try self.jw.objectField("nodeType");
|
||||
try self.jw.write(9);
|
||||
}
|
||||
|
||||
try self.jw.objectField("children");
|
||||
try self.jw.beginArray();
|
||||
|
||||
if (data.options != null) {
|
||||
// Signal to not walk children, as we handled them natively
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn leave(self: *JsonVisitor) !void {
|
||||
try self.jw.endArray();
|
||||
try self.jw.endObject();
|
||||
}
|
||||
};
|
||||
|
||||
fn isStructuralRole(role: []const u8) bool {
|
||||
const structural_roles = std.StaticStringMap(void).initComptime(.{
|
||||
.{ "none", {} },
|
||||
.{ "generic", {} },
|
||||
.{ "InlineTextBox", {} },
|
||||
.{ "banner", {} },
|
||||
.{ "navigation", {} },
|
||||
.{ "main", {} },
|
||||
.{ "list", {} },
|
||||
.{ "listitem", {} },
|
||||
.{ "table", {} },
|
||||
.{ "rowgroup", {} },
|
||||
.{ "row", {} },
|
||||
.{ "cell", {} },
|
||||
.{ "region", {} },
|
||||
});
|
||||
return structural_roles.has(role);
|
||||
}
|
||||
|
||||
const TextVisitor = struct {
|
||||
writer: *std.Io.Writer,
|
||||
tree: Self,
|
||||
depth: usize,
|
||||
|
||||
pub fn visit(self: *TextVisitor, node: *Node, data: *NodeData) !bool {
|
||||
for (0..self.depth) |_| {
|
||||
try self.writer.writeByte(' ');
|
||||
}
|
||||
|
||||
var name_to_print: ?[]const u8 = null;
|
||||
if (data.name) |n| {
|
||||
if (n.len > 0) {
|
||||
name_to_print = n;
|
||||
}
|
||||
} else if (node.is(CData.Text)) |text_node| {
|
||||
const trimmed = std.mem.trim(u8, text_node.getWholeText(), " \t\r\n");
|
||||
if (trimmed.len > 0) {
|
||||
name_to_print = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
const is_text_only = std.mem.eql(u8, data.role, "StaticText") or std.mem.eql(u8, data.role, "none") or std.mem.eql(u8, data.role, "generic");
|
||||
|
||||
try self.writer.print("{d}", .{data.id});
|
||||
if (!is_text_only) {
|
||||
try self.writer.print(" {s}", .{data.role});
|
||||
}
|
||||
if (name_to_print) |n| {
|
||||
try self.writer.print(" '{s}'", .{n});
|
||||
}
|
||||
|
||||
if (data.value) |v| {
|
||||
if (v.len > 0) {
|
||||
try self.writer.print(" value='{s}'", .{v});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.options) |options| {
|
||||
try self.writer.writeAll(" options=[");
|
||||
for (options, 0..) |opt, i| {
|
||||
if (i > 0) try self.writer.writeAll(",");
|
||||
try self.writer.print("'{s}'", .{opt.value});
|
||||
if (opt.selected) {
|
||||
try self.writer.writeAll("*");
|
||||
}
|
||||
}
|
||||
try self.writer.writeAll("]\n");
|
||||
self.depth += 1;
|
||||
return false; // Native handling complete, do not walk children
|
||||
}
|
||||
|
||||
try self.writer.writeByte('\n');
|
||||
self.depth += 1;
|
||||
|
||||
// If this is a leaf-like semantic node and we already have a name,
|
||||
// skip children to avoid redundant StaticText or noise.
|
||||
const is_leaf_semantic = std.mem.eql(u8, data.role, "link") or
|
||||
std.mem.eql(u8, data.role, "button") or
|
||||
std.mem.eql(u8, data.role, "heading") or
|
||||
std.mem.eql(u8, data.role, "code");
|
||||
if (is_leaf_semantic and data.name != null and data.name.?.len > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn leave(self: *TextVisitor) !void {
|
||||
if (self.depth > 0) {
|
||||
self.depth -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "SemanticTree backendDOMNodeId" {
|
||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var page = try testing.pageTest("cdp/registry1.html");
|
||||
defer testing.reset();
|
||||
defer page._session.removePage();
|
||||
|
||||
const st: Self = .{
|
||||
.dom_node = page.window._document.asNode(),
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.arena = testing.arena_allocator,
|
||||
.prune = false,
|
||||
.interactive_only = false,
|
||||
.max_depth = std.math.maxInt(u32) - 1,
|
||||
};
|
||||
|
||||
const json_str = try std.json.Stringify.valueAlloc(testing.allocator, st, .{});
|
||||
defer testing.allocator.free(json_str);
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, json_str, "\"backendDOMNodeId\":") != null);
|
||||
}
|
||||
|
||||
test "SemanticTree max_depth" {
|
||||
var registry: CDPNode.Registry = .init(testing.allocator);
|
||||
defer registry.deinit();
|
||||
|
||||
var page = try testing.pageTest("cdp/registry1.html");
|
||||
defer testing.reset();
|
||||
defer page._session.removePage();
|
||||
|
||||
const st: Self = .{
|
||||
.dom_node = page.window._document.asNode(),
|
||||
.registry = ®istry,
|
||||
.page = page,
|
||||
.arena = testing.arena_allocator,
|
||||
.prune = false,
|
||||
.interactive_only = false,
|
||||
.max_depth = 1,
|
||||
};
|
||||
|
||||
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer aw.deinit();
|
||||
|
||||
try st.textStringify(&aw.writer);
|
||||
const text_str = aw.written();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, text_str, "other") == null);
|
||||
}
|
||||
935
src/Server.zig
935
src/Server.zig
@@ -1,935 +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 lp = @import("lightpanda");
|
||||
const net = std.net;
|
||||
const posix = std.posix;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const App = @import("App.zig");
|
||||
const Config = @import("Config.zig");
|
||||
const CDP = @import("cdp/cdp.zig").CDP;
|
||||
const Net = @import("network/websocket.zig");
|
||||
const HttpClient = @import("browser/HttpClient.zig");
|
||||
|
||||
const Server = @This();
|
||||
|
||||
app: *App,
|
||||
allocator: Allocator,
|
||||
json_version_response: []const u8,
|
||||
|
||||
// Thread management
|
||||
active_threads: std.atomic.Value(u32) = .init(0),
|
||||
clients: std.ArrayList(*Client) = .{},
|
||||
client_mutex: std.Thread.Mutex = .{},
|
||||
clients_pool: std.heap.MemoryPool(Client),
|
||||
|
||||
pub fn init(app: *App, address: net.Address) !*Server {
|
||||
const allocator = app.allocator;
|
||||
const json_version_response = try buildJSONVersionResponse(allocator, address);
|
||||
errdefer allocator.free(json_version_response);
|
||||
|
||||
const self = try allocator.create(Server);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
self.* = .{
|
||||
.app = app,
|
||||
.allocator = allocator,
|
||||
.json_version_response = json_version_response,
|
||||
.clients_pool = std.heap.MemoryPool(Client).init(allocator),
|
||||
};
|
||||
|
||||
try self.app.network.bind(address, self, onAccept);
|
||||
log.info(.app, "server running", .{ .address = address });
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
pub fn shutdown(self: *Server) void {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
|
||||
for (self.clients.items) |client| {
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Server) void {
|
||||
self.shutdown();
|
||||
self.joinThreads();
|
||||
self.clients.deinit(self.allocator);
|
||||
self.clients_pool.deinit();
|
||||
self.allocator.free(self.json_version_response);
|
||||
self.allocator.destroy(self);
|
||||
}
|
||||
|
||||
fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void {
|
||||
const self: *Server = @ptrCast(@alignCast(ctx));
|
||||
const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout());
|
||||
self.spawnWorker(socket, timeout_ms) catch |err| {
|
||||
log.err(.app, "CDP spawn", .{ .err = err });
|
||||
posix.close(socket);
|
||||
};
|
||||
}
|
||||
|
||||
fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
|
||||
defer posix.close(socket);
|
||||
|
||||
// Client is HUGE (> 512KB) because it has a large read buffer.
|
||||
// V8 crashes if this is on the stack (likely related to its size).
|
||||
const client = self.getClient() catch |err| {
|
||||
log.err(.app, "CDP client create", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
defer self.releaseClient(client);
|
||||
|
||||
client.* = Client.init(
|
||||
socket,
|
||||
self.allocator,
|
||||
self.app,
|
||||
self.json_version_response,
|
||||
timeout_ms,
|
||||
) catch |err| {
|
||||
log.err(.app, "CDP client init", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
defer client.deinit();
|
||||
|
||||
self.registerClient(client);
|
||||
defer self.unregisterClient(client);
|
||||
|
||||
// Check shutdown after registering to avoid missing the stop signal.
|
||||
// If deinit() already iterated over clients, this client won't receive stop()
|
||||
// and would block joinThreads() indefinitely.
|
||||
if (self.app.shutdown()) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.start();
|
||||
}
|
||||
|
||||
fn getClient(self: *Server) !*Client {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
return self.clients_pool.create();
|
||||
}
|
||||
|
||||
fn releaseClient(self: *Server, client: *Client) void {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
self.clients_pool.destroy(client);
|
||||
}
|
||||
|
||||
fn registerClient(self: *Server, client: *Client) void {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
self.clients.append(self.allocator, client) catch {};
|
||||
}
|
||||
|
||||
fn unregisterClient(self: *Server, client: *Client) void {
|
||||
self.client_mutex.lock();
|
||||
defer self.client_mutex.unlock();
|
||||
for (self.clients.items, 0..) |c, i| {
|
||||
if (c == client) {
|
||||
_ = self.clients.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void {
|
||||
if (self.app.shutdown()) {
|
||||
return error.ShuttingDown;
|
||||
}
|
||||
|
||||
// Atomically increment active_threads only if below max_connections.
|
||||
// Uses CAS loop to avoid race between checking the limit and incrementing.
|
||||
//
|
||||
// cmpxchgWeak may fail for two reasons:
|
||||
// 1. Another thread changed the value (increment or decrement)
|
||||
// 2. Spurious failure on some architectures (e.g. ARM)
|
||||
//
|
||||
// We use Weak instead of Strong because we need a retry loop anyway:
|
||||
// if CAS fails because a thread finished (counter decreased), we should
|
||||
// retry rather than return an error - there may now be room for a new connection.
|
||||
//
|
||||
// On failure, cmpxchgWeak returns the actual value, which we reuse to avoid
|
||||
// an extra load on the next iteration.
|
||||
const max_connections = self.app.config.maxConnections();
|
||||
var current = self.active_threads.load(.monotonic);
|
||||
while (current < max_connections) {
|
||||
current = self.active_threads.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break;
|
||||
} else {
|
||||
return error.MaxThreadsReached;
|
||||
}
|
||||
errdefer _ = self.active_threads.fetchSub(1, .monotonic);
|
||||
|
||||
const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms });
|
||||
thread.detach();
|
||||
}
|
||||
|
||||
fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void {
|
||||
defer _ = self.active_threads.fetchSub(1, .monotonic);
|
||||
handleConnection(self, socket, timeout_ms);
|
||||
}
|
||||
|
||||
fn joinThreads(self: *Server) void {
|
||||
while (self.active_threads.load(.monotonic) > 0) {
|
||||
std.Thread.sleep(10 * std.time.ns_per_ms);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle exactly one TCP connection.
|
||||
pub const Client = struct {
|
||||
// The client is initially serving HTTP requests but, under normal circumstances
|
||||
// should eventually be upgraded to a websocket connections
|
||||
mode: union(enum) {
|
||||
http: void,
|
||||
cdp: CDP,
|
||||
},
|
||||
|
||||
allocator: Allocator,
|
||||
app: *App,
|
||||
http: *HttpClient,
|
||||
ws: Net.WsConnection,
|
||||
|
||||
fn init(
|
||||
socket: posix.socket_t,
|
||||
allocator: Allocator,
|
||||
app: *App,
|
||||
json_version_response: []const u8,
|
||||
timeout_ms: u32,
|
||||
) !Client {
|
||||
var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms);
|
||||
errdefer ws.deinit();
|
||||
|
||||
if (log.enabled(.app, .info)) {
|
||||
const client_address = ws.getAddress() catch null;
|
||||
log.info(.app, "client connected", .{ .ip = client_address });
|
||||
}
|
||||
|
||||
const http = try HttpClient.init(allocator, &app.network);
|
||||
errdefer http.deinit();
|
||||
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.app = app,
|
||||
.http = http,
|
||||
.ws = ws,
|
||||
.mode = .{ .http = {} },
|
||||
};
|
||||
}
|
||||
|
||||
fn stop(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.http => {},
|
||||
.cdp => |*cdp| {
|
||||
cdp.browser.env.terminate();
|
||||
self.ws.sendClose();
|
||||
},
|
||||
}
|
||||
self.ws.shutdown();
|
||||
}
|
||||
|
||||
fn deinit(self: *Client) void {
|
||||
switch (self.mode) {
|
||||
.cdp => |*cdp| cdp.deinit(),
|
||||
.http => {},
|
||||
}
|
||||
self.ws.deinit();
|
||||
self.http.deinit();
|
||||
}
|
||||
|
||||
fn start(self: *Client) void {
|
||||
const http = self.http;
|
||||
http.cdp_client = .{
|
||||
.socket = self.ws.socket,
|
||||
.ctx = self,
|
||||
.blocking_read_start = Client.blockingReadStart,
|
||||
.blocking_read = Client.blockingRead,
|
||||
.blocking_read_end = Client.blockingReadStop,
|
||||
};
|
||||
defer http.cdp_client = null;
|
||||
|
||||
self.httpLoop(http) catch |err| {
|
||||
log.err(.app, "CDP client loop", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
fn httpLoop(self: *Client, http: *HttpClient) !void {
|
||||
lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{});
|
||||
|
||||
while (true) {
|
||||
const status = http.tick(self.ws.timeout_ms) catch |err| {
|
||||
log.err(.app, "http tick", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
if (status != .cdp_socket) {
|
||||
log.info(.app, "CDP timeout", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.readSocket() == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.mode == .cdp) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var cdp = &self.mode.cdp;
|
||||
var last_message = milliTimestamp(.monotonic);
|
||||
var ms_remaining = self.ws.timeout_ms;
|
||||
|
||||
while (true) {
|
||||
switch (cdp.pageWait(ms_remaining)) {
|
||||
.cdp_socket => {
|
||||
if (self.readSocket() == false) {
|
||||
return;
|
||||
}
|
||||
last_message = milliTimestamp(.monotonic);
|
||||
ms_remaining = self.ws.timeout_ms;
|
||||
},
|
||||
.no_page => {
|
||||
const status = http.tick(ms_remaining) catch |err| {
|
||||
log.err(.app, "http tick", .{ .err = err });
|
||||
return;
|
||||
};
|
||||
if (status != .cdp_socket) {
|
||||
log.info(.app, "CDP timeout", .{});
|
||||
return;
|
||||
}
|
||||
if (self.readSocket() == false) {
|
||||
return;
|
||||
}
|
||||
last_message = milliTimestamp(.monotonic);
|
||||
ms_remaining = self.ws.timeout_ms;
|
||||
},
|
||||
.done => {
|
||||
const now = milliTimestamp(.monotonic);
|
||||
const elapsed = now - last_message;
|
||||
if (elapsed >= ms_remaining) {
|
||||
log.info(.app, "CDP timeout", .{});
|
||||
return;
|
||||
}
|
||||
ms_remaining -= @intCast(elapsed);
|
||||
last_message = now;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blockingReadStart(ctx: *anyopaque) bool {
|
||||
const self: *Client = @ptrCast(@alignCast(ctx));
|
||||
self.ws.setBlocking(true) catch |err| {
|
||||
log.warn(.app, "CDP blockingReadStart", .{ .err = err });
|
||||
return false;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
fn blockingRead(ctx: *anyopaque) bool {
|
||||
const self: *Client = @ptrCast(@alignCast(ctx));
|
||||
return self.readSocket();
|
||||
}
|
||||
|
||||
fn blockingReadStop(ctx: *anyopaque) bool {
|
||||
const self: *Client = @ptrCast(@alignCast(ctx));
|
||||
self.ws.setBlocking(false) catch |err| {
|
||||
log.warn(.app, "CDP blockingReadStop", .{ .err = err });
|
||||
return false;
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
fn readSocket(self: *Client) bool {
|
||||
const n = self.ws.read() catch |err| {
|
||||
log.warn(.app, "CDP read", .{ .err = err });
|
||||
return false;
|
||||
};
|
||||
|
||||
if (n == 0) {
|
||||
log.info(.app, "CDP disconnect", .{});
|
||||
return false;
|
||||
}
|
||||
|
||||
return self.processData() catch false;
|
||||
}
|
||||
|
||||
fn processData(self: *Client) !bool {
|
||||
switch (self.mode) {
|
||||
.cdp => |*cdp| return self.processWebsocketMessage(cdp),
|
||||
.http => return self.processHTTPRequest(),
|
||||
}
|
||||
}
|
||||
|
||||
fn processHTTPRequest(self: *Client) !bool {
|
||||
lp.assert(self.ws.reader.pos == 0, "Client.HTTP pos", .{ .pos = self.ws.reader.pos });
|
||||
const request = self.ws.reader.buf[0..self.ws.reader.len];
|
||||
|
||||
if (request.len > Config.CDP_MAX_HTTP_REQUEST_SIZE) {
|
||||
self.writeHTTPErrorResponse(413, "Request too large");
|
||||
return error.RequestTooLarge;
|
||||
}
|
||||
|
||||
// we're only expecting [body-less] GET requests.
|
||||
if (std.mem.endsWith(u8, request, "\r\n\r\n") == false) {
|
||||
// we need more data, put any more data here
|
||||
return true;
|
||||
}
|
||||
|
||||
// the next incoming data can go to the front of our buffer
|
||||
defer self.ws.reader.len = 0;
|
||||
return self.handleHTTPRequest(request) catch |err| {
|
||||
switch (err) {
|
||||
error.NotFound => self.writeHTTPErrorResponse(404, "Not found"),
|
||||
error.InvalidRequest => self.writeHTTPErrorResponse(400, "Invalid request"),
|
||||
error.InvalidProtocol => self.writeHTTPErrorResponse(400, "Invalid HTTP protocol"),
|
||||
error.MissingHeaders => self.writeHTTPErrorResponse(400, "Missing required header"),
|
||||
error.InvalidUpgradeHeader => self.writeHTTPErrorResponse(400, "Unsupported upgrade type"),
|
||||
error.InvalidVersionHeader => self.writeHTTPErrorResponse(400, "Invalid websocket version"),
|
||||
error.InvalidConnectionHeader => self.writeHTTPErrorResponse(400, "Invalid connection header"),
|
||||
else => {
|
||||
log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] });
|
||||
self.writeHTTPErrorResponse(500, "Internal Server Error");
|
||||
},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn handleHTTPRequest(self: *Client, request: []u8) !bool {
|
||||
if (request.len < 18) {
|
||||
// 18 is [generously] the smallest acceptable HTTP request
|
||||
return error.InvalidRequest;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, request[0..4], "GET ") == false) {
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse {
|
||||
return error.InvalidRequest;
|
||||
};
|
||||
|
||||
const url = request[4..url_end];
|
||||
|
||||
if (std.mem.eql(u8, url, "/")) {
|
||||
try self.upgradeConnection(request);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
|
||||
try self.ws.send(self.ws.json_version_response);
|
||||
// Chromedp (a Go driver) does an http request to /json/version
|
||||
// then to / (websocket upgrade) using a different connection.
|
||||
// Since we only allow 1 connection at a time, the 2nd one (the
|
||||
// websocket upgrade) blocks until the first one times out.
|
||||
// We can avoid that by closing the connection. json_version_response
|
||||
// has a Connection: Close header too.
|
||||
self.ws.shutdown();
|
||||
return false;
|
||||
}
|
||||
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
fn upgradeConnection(self: *Client, request: []u8) !void {
|
||||
try self.ws.upgrade(request);
|
||||
self.mode = .{ .cdp = try CDP.init(self.app, self.http, self) };
|
||||
}
|
||||
|
||||
fn writeHTTPErrorResponse(self: *Client, comptime status: u16, comptime body: []const u8) void {
|
||||
self.ws.sendHttpError(status, body);
|
||||
}
|
||||
|
||||
fn processWebsocketMessage(self: *Client, cdp: *CDP) !bool {
|
||||
return self.ws.processMessages(cdp);
|
||||
}
|
||||
|
||||
pub fn sendAllocator(self: *Client) Allocator {
|
||||
return self.ws.send_arena.allocator();
|
||||
}
|
||||
|
||||
pub fn sendJSON(self: *Client, message: anytype, opts: std.json.Stringify.Options) !void {
|
||||
return self.ws.sendJSON(message, opts);
|
||||
}
|
||||
|
||||
pub fn sendJSONRaw(self: *Client, buf: std.ArrayList(u8)) !void {
|
||||
return self.ws.sendJSONRaw(buf);
|
||||
}
|
||||
};
|
||||
|
||||
// Utils
|
||||
// --------
|
||||
|
||||
fn buildJSONVersionResponse(
|
||||
allocator: Allocator,
|
||||
address: net.Address,
|
||||
) ![]const u8 {
|
||||
const body_format = "{{\"webSocketDebuggerUrl\": \"ws://{f}/\"}}";
|
||||
const body_len = std.fmt.count(body_format, .{address});
|
||||
|
||||
// We send a Connection: Close (and actually close the connection)
|
||||
// because chromedp (Go driver) sends a request to /json/version and then
|
||||
// does an upgrade request, on a different connection. Since we only allow
|
||||
// 1 connection at a time, the upgrade connection doesn't proceed until we
|
||||
// timeout the /json/version. So, instead of waiting for that, we just
|
||||
// always close HTTP requests.
|
||||
const response_format =
|
||||
"HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
body_format;
|
||||
return try std.fmt.allocPrint(allocator, response_format, .{ body_len, address });
|
||||
}
|
||||
|
||||
pub const timestamp = @import("datetime.zig").timestamp;
|
||||
pub const milliTimestamp = @import("datetime.zig").milliTimestamp;
|
||||
|
||||
const testing = std.testing;
|
||||
test "server: buildJSONVersionResponse" {
|
||||
const address = try net.Address.parseIp4("127.0.0.1", 9001);
|
||||
const res = try buildJSONVersionResponse(testing.allocator, address);
|
||||
defer testing.allocator.free(res);
|
||||
|
||||
try testing.expectEqualStrings("HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: 48\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9001/\"}", res);
|
||||
}
|
||||
|
||||
test "Client: http invalid request" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest("GET /over/9000 HTTP/1.1\r\n" ++ "Header: " ++ ("a" ** 4100) ++ "\r\n\r\n");
|
||||
try testing.expectEqualStrings("HTTP/1.1 413 \r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Length: 17\r\n\r\n" ++
|
||||
"Request too large", res);
|
||||
}
|
||||
|
||||
test "Client: http invalid handshake" {
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Invalid request",
|
||||
"\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
404,
|
||||
"Not found",
|
||||
"GET /over/9000 HTTP/1.1\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
404,
|
||||
"Not found",
|
||||
"POST / HTTP/1.1\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Invalid HTTP protocol",
|
||||
"GET / HTTP/1.0\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Missing required header",
|
||||
"GET / HTTP/1.1\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Missing required header",
|
||||
"GET / HTTP/1.1\r\nConnection: upgrade\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Missing required header",
|
||||
"GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\n\r\n",
|
||||
);
|
||||
|
||||
try assertHTTPError(
|
||||
400,
|
||||
"Missing required header",
|
||||
"GET / HTTP/1.1\r\nConnection: upgrade\r\nUpgrade: websocket\r\nsec-websocket-version:13\r\n\r\n",
|
||||
);
|
||||
}
|
||||
|
||||
test "Client: http valid handshake" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const request =
|
||||
"GET / HTTP/1.1\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"sec-websocket-version:13\r\n" ++
|
||||
"sec-websocket-key: this is my key\r\n" ++
|
||||
"Custom: Header-Value\r\n\r\n";
|
||||
|
||||
const res = try c.httpRequest(request);
|
||||
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
|
||||
}
|
||||
|
||||
test "Client: read invalid websocket message" {
|
||||
// 131 = 128 (fin) | 3 where 3 isn't a valid type
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 131, 128, 'm', 'a', 's', 'k' },
|
||||
);
|
||||
|
||||
for ([_]u8{ 16, 32, 64 }) |rsv| {
|
||||
// none of the reserve flags should be set
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ rsv, 128, 'm', 'a', 's', 'k' },
|
||||
);
|
||||
|
||||
// as a bitmask
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ rsv + 4, 128, 'm', 'a', 's', 'k' },
|
||||
);
|
||||
}
|
||||
|
||||
// client->server messages must be masked
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 129, 1, 'a' },
|
||||
);
|
||||
|
||||
// control types (ping/ping/close) can't be > 125 bytes
|
||||
for ([_]u8{ 136, 137, 138 }) |op| {
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ op, 254, 1, 1 },
|
||||
);
|
||||
}
|
||||
|
||||
// length of message is 0000 0810, i.e: 1024 * 512 + 265
|
||||
try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 8, 1, 0, 'm', 'a', 's', 'k' });
|
||||
|
||||
// continuation type message must come after a normal message
|
||||
// even when not a fin frame
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 0, 129, 'm', 'a', 's', 'k', 'd' },
|
||||
);
|
||||
|
||||
// continuation type message must come after a normal message
|
||||
// even as a fin frame
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 128, 129, 'm', 'a', 's', 'k', 'd' },
|
||||
);
|
||||
|
||||
// text (non-fin) - text (non-fin)
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' },
|
||||
);
|
||||
|
||||
// text (non-fin) - text (fin) should always been continuation after non-fin
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' },
|
||||
);
|
||||
|
||||
// close must be fin
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{
|
||||
8, 129, 'm', 'a', 's', 'k', 'd',
|
||||
},
|
||||
);
|
||||
|
||||
// ping must be fin
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{
|
||||
9, 129, 'm', 'a', 's', 'k', 'd',
|
||||
},
|
||||
);
|
||||
|
||||
// pong must be fin
|
||||
try assertWebSocketError(
|
||||
1002,
|
||||
&.{
|
||||
10, 129, 'm', 'a', 's', 'k', 'd',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test "Client: ping reply" {
|
||||
try assertWebSocketMessage(
|
||||
// fin | pong, len
|
||||
&.{ 138, 0 },
|
||||
|
||||
// fin | ping, masked | len, 4-byte mask
|
||||
&.{ 137, 128, 0, 0, 0, 0 },
|
||||
);
|
||||
|
||||
try assertWebSocketMessage(
|
||||
// fin | pong, len, payload
|
||||
&.{ 138, 5, 100, 96, 97, 109, 104 },
|
||||
|
||||
// fin | ping, masked | len, 4-byte mask, 5 byte payload
|
||||
&.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 },
|
||||
);
|
||||
}
|
||||
|
||||
test "Client: close message" {
|
||||
try assertWebSocketMessage(
|
||||
// fin | close, len, close code (normal)
|
||||
&.{ 136, 2, 3, 232 },
|
||||
|
||||
// fin | close, masked | len, 4-byte mask
|
||||
&.{ 136, 128, 0, 0, 0, 0 },
|
||||
);
|
||||
}
|
||||
|
||||
test "server: 404" {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n");
|
||||
try testing.expectEqualStrings("HTTP/1.1 404 \r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Length: 9\r\n\r\n" ++
|
||||
"Not found", res);
|
||||
}
|
||||
|
||||
test "server: get /json/version" {
|
||||
const expected_response =
|
||||
"HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: 48\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
"{\"webSocketDebuggerUrl\": \"ws://127.0.0.1:9583/\"}";
|
||||
|
||||
{
|
||||
// twice on the same connection
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
|
||||
try testing.expectEqualStrings(expected_response, res1);
|
||||
}
|
||||
|
||||
{
|
||||
// again on a new connection
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res1 = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n");
|
||||
try testing.expectEqualStrings(expected_response, res1);
|
||||
}
|
||||
}
|
||||
|
||||
fn assertHTTPError(
|
||||
comptime expected_status: u16,
|
||||
comptime expected_body: []const u8,
|
||||
input: []const u8,
|
||||
) !void {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
const res = try c.httpRequest(input);
|
||||
const expected_response = std.fmt.comptimePrint(
|
||||
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
|
||||
.{ expected_status, expected_body.len, expected_body },
|
||||
);
|
||||
|
||||
try testing.expectEqualStrings(expected_response, res);
|
||||
}
|
||||
|
||||
fn assertWebSocketError(close_code: u16, input: []const u8) !void {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
try c.handshake();
|
||||
try c.stream.writeAll(input);
|
||||
|
||||
const msg = try c.readWebsocketMessage() orelse return error.NoMessage;
|
||||
defer if (msg.cleanup_fragment) {
|
||||
c.reader.cleanup();
|
||||
};
|
||||
|
||||
try testing.expectEqual(.close, msg.type);
|
||||
try testing.expectEqual(2, msg.data.len);
|
||||
try testing.expectEqual(close_code, std.mem.readInt(u16, msg.data[0..2], .big));
|
||||
}
|
||||
|
||||
fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void {
|
||||
var c = try createTestClient();
|
||||
defer c.deinit();
|
||||
|
||||
try c.handshake();
|
||||
try c.stream.writeAll(input);
|
||||
|
||||
const msg = try c.readWebsocketMessage() orelse return error.NoMessage;
|
||||
defer if (msg.cleanup_fragment) {
|
||||
c.reader.cleanup();
|
||||
};
|
||||
|
||||
const actual = c.reader.buf[0 .. msg.data.len + 2];
|
||||
try testing.expectEqualSlices(u8, expected, actual);
|
||||
}
|
||||
|
||||
const MockCDP = struct {
|
||||
messages: std.ArrayList([]const u8) = .{},
|
||||
|
||||
allocator: Allocator = testing.allocator,
|
||||
|
||||
fn init(_: Allocator, client: anytype) MockCDP {
|
||||
_ = client;
|
||||
return .{};
|
||||
}
|
||||
|
||||
fn deinit(self: *MockCDP) void {
|
||||
const allocator = self.allocator;
|
||||
for (self.messages.items) |msg| {
|
||||
allocator.free(msg);
|
||||
}
|
||||
self.messages.deinit(allocator);
|
||||
}
|
||||
|
||||
fn handleMessage(self: *MockCDP, message: []const u8) bool {
|
||||
const owned = self.allocator.dupe(u8, message) catch unreachable;
|
||||
self.messages.append(self.allocator, owned) catch unreachable;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
fn createTestClient() !TestClient {
|
||||
const address = std.net.Address.initIp4([_]u8{ 127, 0, 0, 1 }, 9583);
|
||||
const stream = try std.net.tcpConnectToAddress(address);
|
||||
|
||||
const timeout = std.mem.toBytes(posix.timeval{
|
||||
.sec = 2,
|
||||
.usec = 0,
|
||||
});
|
||||
try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout);
|
||||
try posix.setsockopt(stream.handle, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout);
|
||||
return .{
|
||||
.stream = stream,
|
||||
.reader = .{
|
||||
.allocator = testing.allocator,
|
||||
.buf = try testing.allocator.alloc(u8, 1024 * 16),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TestClient = struct {
|
||||
stream: std.net.Stream,
|
||||
buf: [1024]u8 = undefined,
|
||||
reader: Net.Reader(false),
|
||||
|
||||
fn deinit(self: *TestClient) void {
|
||||
self.stream.close();
|
||||
self.reader.deinit();
|
||||
}
|
||||
|
||||
fn httpRequest(self: *TestClient, req: []const u8) ![]const u8 {
|
||||
try self.stream.writeAll(req);
|
||||
|
||||
var pos: usize = 0;
|
||||
var total_length: ?usize = null;
|
||||
while (true) {
|
||||
pos += try self.stream.read(self.buf[pos..]);
|
||||
if (pos == 0) {
|
||||
return error.NoMoreData;
|
||||
}
|
||||
const response = self.buf[0..pos];
|
||||
if (total_length == null) {
|
||||
const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue;
|
||||
const header = response[0 .. header_end + 4];
|
||||
|
||||
const cl = blk: {
|
||||
const cl_header = "Content-Length: ";
|
||||
const start = (std.mem.indexOf(u8, header, cl_header) orelse {
|
||||
break :blk 0;
|
||||
}) + cl_header.len;
|
||||
|
||||
const end = std.mem.indexOfScalarPos(u8, header, start, '\r') orelse {
|
||||
return error.InvalidContentLength;
|
||||
};
|
||||
|
||||
break :blk std.fmt.parseInt(usize, header[start..end], 10) catch {
|
||||
return error.InvalidContentLength;
|
||||
};
|
||||
};
|
||||
|
||||
total_length = cl + header.len;
|
||||
}
|
||||
|
||||
if (total_length) |tl| {
|
||||
if (pos == tl) {
|
||||
return response;
|
||||
}
|
||||
if (pos > tl) {
|
||||
return error.DataExceedsContentLength;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handshake(self: *TestClient) !void {
|
||||
const request =
|
||||
"GET / HTTP/1.1\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"sec-websocket-version:13\r\n" ++
|
||||
"sec-websocket-key: this is my key\r\n" ++
|
||||
"Custom: Header-Value\r\n\r\n";
|
||||
|
||||
const res = try self.httpRequest(request);
|
||||
try testing.expectEqualStrings("HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res);
|
||||
}
|
||||
|
||||
fn readWebsocketMessage(self: *TestClient) !?Net.Message {
|
||||
while (true) {
|
||||
const n = try self.stream.read(self.reader.readBuf());
|
||||
if (n == 0) {
|
||||
return error.Closed;
|
||||
}
|
||||
self.reader.len += n;
|
||||
if (try self.reader.next()) |msg| {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,107 +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/>.
|
||||
|
||||
//! This structure processes operating system signals (SIGINT, SIGTERM)
|
||||
//! and runs callbacks to clean up the system gracefully.
|
||||
//!
|
||||
//! The structure does not clear the memory allocated in the arena,
|
||||
//! clear the entire arena when exiting the program.
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
const SigHandler = @This();
|
||||
|
||||
arena: Allocator,
|
||||
|
||||
sigset: std.posix.sigset_t = undefined,
|
||||
handle_thread: ?std.Thread = null,
|
||||
|
||||
attempt: u32 = 0,
|
||||
listeners: std.ArrayList(Listener) = .empty,
|
||||
|
||||
pub const Listener = struct {
|
||||
args: []const u8,
|
||||
start: *const fn (context: *const anyopaque) void,
|
||||
};
|
||||
|
||||
pub fn install(self: *SigHandler) !void {
|
||||
// Block SIGINT and SIGTERM for the current thread and all created from it
|
||||
self.sigset = std.posix.sigemptyset();
|
||||
std.posix.sigaddset(&self.sigset, std.posix.SIG.INT);
|
||||
std.posix.sigaddset(&self.sigset, std.posix.SIG.TERM);
|
||||
std.posix.sigaddset(&self.sigset, std.posix.SIG.QUIT);
|
||||
std.posix.sigprocmask(std.posix.SIG.BLOCK, &self.sigset, null);
|
||||
|
||||
self.handle_thread = try std.Thread.spawn(.{ .allocator = self.arena }, SigHandler.sighandle, .{self});
|
||||
self.handle_thread.?.detach();
|
||||
}
|
||||
|
||||
pub fn on(self: *SigHandler, func: anytype, args: std.meta.ArgsTuple(@TypeOf(func))) !void {
|
||||
assert(@typeInfo(@TypeOf(func)).@"fn".return_type.? == void);
|
||||
|
||||
const Args = @TypeOf(args);
|
||||
const TypeErased = struct {
|
||||
fn start(context: *const anyopaque) void {
|
||||
const args_casted: *const Args = @ptrCast(@alignCast(context));
|
||||
@call(.auto, func, args_casted.*);
|
||||
}
|
||||
};
|
||||
|
||||
const buffer = try self.arena.alignedAlloc(u8, .of(Args), @sizeOf(Args));
|
||||
errdefer self.arena.free(buffer);
|
||||
|
||||
const bytes: []const u8 = @ptrCast((&args)[0..1]);
|
||||
@memcpy(buffer, bytes);
|
||||
|
||||
try self.listeners.append(self.arena, .{
|
||||
.args = buffer,
|
||||
.start = TypeErased.start,
|
||||
});
|
||||
}
|
||||
|
||||
fn sighandle(self: *SigHandler) noreturn {
|
||||
while (true) {
|
||||
var sig: c_int = 0;
|
||||
|
||||
const rc = std.c.sigwait(&self.sigset, &sig);
|
||||
if (rc != 0) {
|
||||
log.err(.app, "Unable to process signal {}", .{rc});
|
||||
std.process.exit(1);
|
||||
}
|
||||
|
||||
switch (sig) {
|
||||
std.posix.SIG.INT, std.posix.SIG.TERM => {
|
||||
if (self.attempt > 1) {
|
||||
std.process.exit(1);
|
||||
}
|
||||
self.attempt += 1;
|
||||
|
||||
log.info(.app, "Received termination signal...", .{});
|
||||
for (self.listeners.items) |*item| {
|
||||
item.start(item.args.ptr);
|
||||
}
|
||||
continue;
|
||||
},
|
||||
else => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +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 URL = @import("browser/URL.zig");
|
||||
|
||||
const TestHTTPServer = @This();
|
||||
|
||||
shutdown: std.atomic.Value(bool),
|
||||
listener: ?std.net.Server,
|
||||
handler: Handler,
|
||||
|
||||
const Handler = *const fn (req: *std.http.Server.Request) anyerror!void;
|
||||
|
||||
pub fn init(handler: Handler) TestHTTPServer {
|
||||
return .{
|
||||
.shutdown = .init(true),
|
||||
.listener = null,
|
||||
.handler = handler,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *TestHTTPServer) void {
|
||||
self.listener = null;
|
||||
}
|
||||
|
||||
pub fn stop(self: *TestHTTPServer) void {
|
||||
self.shutdown.store(true, .release);
|
||||
if (self.listener) |*listener| {
|
||||
switch (@import("builtin").target.os.tag) {
|
||||
.linux => std.posix.shutdown(listener.stream.handle, .recv) catch {},
|
||||
else => std.posix.close(listener.stream.handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(self: *TestHTTPServer, wg: *std.Thread.WaitGroup) !void {
|
||||
const address = try std.net.Address.parseIp("127.0.0.1", 9582);
|
||||
|
||||
self.listener = try address.listen(.{ .reuse_address = true });
|
||||
var listener = &self.listener.?;
|
||||
self.shutdown.store(false, .release);
|
||||
|
||||
wg.finish();
|
||||
|
||||
while (true) {
|
||||
const conn = listener.accept() catch |err| {
|
||||
if (self.shutdown.load(.acquire) or err == error.SocketNotListening) {
|
||||
return;
|
||||
}
|
||||
return err;
|
||||
};
|
||||
const thrd = try std.Thread.spawn(.{}, handleConnection, .{ self, conn });
|
||||
thrd.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn handleConnection(self: *TestHTTPServer, conn: std.net.Server.Connection) !void {
|
||||
defer conn.stream.close();
|
||||
|
||||
var req_buf: [2048]u8 = undefined;
|
||||
var conn_reader = conn.stream.reader(&req_buf);
|
||||
var conn_writer = conn.stream.writer(&req_buf);
|
||||
|
||||
var http_server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);
|
||||
|
||||
while (true) {
|
||||
var req = http_server.receiveHead() catch |err| switch (err) {
|
||||
error.ReadFailed => continue,
|
||||
error.HttpConnectionClosing => continue,
|
||||
else => {
|
||||
std.debug.print("Test HTTP Server error: {}\n", .{err});
|
||||
return err;
|
||||
},
|
||||
};
|
||||
|
||||
self.handler(&req) catch |err| {
|
||||
std.debug.print("test http error '{s}': {}\n", .{ req.head.target, err });
|
||||
try req.respond("server error", .{ .status = .internal_server_error });
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sendFile(req: *std.http.Server.Request, file_path: []const u8) !void {
|
||||
var url_buf: [1024]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&url_buf);
|
||||
const unescaped_file_path = try URL.unescape(fba.allocator(), file_path);
|
||||
var file = std.fs.cwd().openFile(unescaped_file_path, .{}) catch |err| switch (err) {
|
||||
error.FileNotFound => return req.respond("server error", .{ .status = .not_found }),
|
||||
else => return err,
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
const stat = try file.stat();
|
||||
var send_buffer: [4096]u8 = undefined;
|
||||
|
||||
var res = try req.respondStreaming(&send_buffer, .{
|
||||
.content_length = stat.size,
|
||||
.respond_options = .{
|
||||
.extra_headers = &.{
|
||||
.{ .name = "content-type", .value = getContentType(file_path) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
var read_buffer: [4096]u8 = undefined;
|
||||
var reader = file.reader(&read_buffer);
|
||||
_ = try res.writer.sendFileAll(&reader, .unlimited);
|
||||
try res.writer.flush();
|
||||
try res.end();
|
||||
}
|
||||
|
||||
fn getContentType(file_path: []const u8) []const u8 {
|
||||
if (std.mem.endsWith(u8, file_path, ".js")) {
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, file_path, ".html")) {
|
||||
return "text/html";
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, file_path, ".htm")) {
|
||||
return "text/html";
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, file_path, ".xml")) {
|
||||
// some wpt tests do this
|
||||
return "text/xml";
|
||||
}
|
||||
|
||||
if (std.mem.endsWith(u8, file_path, ".mjs")) {
|
||||
// mjs are ECMAScript modules
|
||||
return "application/json";
|
||||
}
|
||||
|
||||
std.debug.print("TestHTTPServer asked to serve an unknown file type: {s}\n", .{file_path});
|
||||
return "text/html";
|
||||
}
|
||||
100
src/app.zig
Normal file
100
src/app.zig
Normal file
@@ -0,0 +1,100 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const log = @import("log.zig");
|
||||
const Loop = @import("runtime/loop.zig").Loop;
|
||||
const HttpClient = @import("http/client.zig").Client;
|
||||
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,
|
||||
allocator: Allocator,
|
||||
telemetry: Telemetry,
|
||||
http_client: HttpClient,
|
||||
app_dir_path: ?[]const u8,
|
||||
notification: *Notification,
|
||||
|
||||
pub const RunMode = enum {
|
||||
help,
|
||||
fetch,
|
||||
serve,
|
||||
version,
|
||||
};
|
||||
|
||||
pub const Config = struct {
|
||||
run_mode: RunMode,
|
||||
tls_verify_host: bool = true,
|
||||
http_proxy: ?std.Uri = 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,
|
||||
.app_dir_path = app_dir_path,
|
||||
.notification = notification,
|
||||
.http_client = try HttpClient.init(allocator, .{
|
||||
.max_concurrent = 3,
|
||||
.http_proxy = config.http_proxy,
|
||||
.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,122 +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 Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
const HttpClient = @import("HttpClient.zig");
|
||||
|
||||
const ArenaPool = App.ArenaPool;
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
|
||||
const Session = @import("Session.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
|
||||
// Browser is an instance of the browser.
|
||||
// You can create multiple browser instances.
|
||||
// A browser contains only one session.
|
||||
const Browser = @This();
|
||||
|
||||
env: js.Env,
|
||||
app: *App,
|
||||
session: ?Session,
|
||||
allocator: Allocator,
|
||||
arena_pool: *ArenaPool,
|
||||
http_client: *HttpClient,
|
||||
|
||||
const InitOpts = struct {
|
||||
env: js.Env.InitOpts = .{},
|
||||
http_client: *HttpClient,
|
||||
};
|
||||
|
||||
pub fn init(app: *App, opts: InitOpts) !Browser {
|
||||
const allocator = app.allocator;
|
||||
|
||||
var env = try js.Env.init(app, opts.env);
|
||||
errdefer env.deinit();
|
||||
|
||||
return .{
|
||||
.app = app,
|
||||
.env = env,
|
||||
.session = null,
|
||||
.allocator = allocator,
|
||||
.arena_pool = &app.arena_pool,
|
||||
.http_client = opts.http_client,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Browser) void {
|
||||
self.closeSession();
|
||||
self.env.deinit();
|
||||
}
|
||||
|
||||
pub fn newSession(self: *Browser, notification: *Notification) !*Session {
|
||||
self.closeSession();
|
||||
self.session = @as(Session, undefined);
|
||||
const session = &self.session.?;
|
||||
try Session.init(session, self, notification);
|
||||
return session;
|
||||
}
|
||||
|
||||
pub fn closeSession(self: *Browser) void {
|
||||
if (self.session) |*session| {
|
||||
session.deinit();
|
||||
self.session = null;
|
||||
self.env.memoryPressureNotification(.critical);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runMicrotasks(self: *Browser) void {
|
||||
self.env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn runMacrotasks(self: *Browser) !void {
|
||||
const env = &self.env;
|
||||
|
||||
try self.env.runMacrotasks();
|
||||
env.pumpMessageLoop();
|
||||
|
||||
// either of the above could have queued more microtasks
|
||||
env.runMicrotasks();
|
||||
}
|
||||
|
||||
pub fn hasBackgroundTasks(self: *Browser) bool {
|
||||
return self.env.hasBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn waitForBackgroundTasks(self: *Browser) void {
|
||||
self.env.waitForBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn msToNextMacrotask(self: *Browser) ?u64 {
|
||||
return self.env.msToNextMacrotask();
|
||||
}
|
||||
|
||||
pub fn msTo(self: *Browser) bool {
|
||||
return self.env.hasBackgroundTasks();
|
||||
}
|
||||
|
||||
pub fn runIdleTasks(self: *const Browser) void {
|
||||
self.env.runIdleTasks();
|
||||
}
|
||||
@@ -1,943 +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 builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const Page = @import("Page.zig");
|
||||
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
const EventKey = struct {
|
||||
event_target: usize,
|
||||
type_string: String,
|
||||
};
|
||||
|
||||
const EventKeyContext = struct {
|
||||
pub fn hash(_: @This(), key: EventKey) u64 {
|
||||
var hasher = std.hash.Wyhash.init(0);
|
||||
hasher.update(std.mem.asBytes(&key.event_target));
|
||||
hasher.update(key.type_string.str());
|
||||
return hasher.final();
|
||||
}
|
||||
|
||||
pub fn eql(_: @This(), a: EventKey, b: EventKey) bool {
|
||||
return a.event_target == b.event_target and a.type_string.eql(b.type_string);
|
||||
}
|
||||
};
|
||||
|
||||
pub const EventManager = @This();
|
||||
|
||||
page: *Page,
|
||||
arena: Allocator,
|
||||
// Used as an optimization in Page._documentIsComplete. If we know there are no
|
||||
// 'load' listeners in the document, we can skip dispatching the per-resource
|
||||
// 'load' event (e.g. amazon product page has no listener and ~350 resources)
|
||||
has_dom_load_listener: bool,
|
||||
listener_pool: std.heap.MemoryPool(Listener),
|
||||
ignore_list: std.ArrayList(*Listener),
|
||||
list_pool: std.heap.MemoryPool(std.DoublyLinkedList),
|
||||
lookup: std.HashMapUnmanaged(
|
||||
EventKey,
|
||||
*std.DoublyLinkedList,
|
||||
EventKeyContext,
|
||||
std.hash_map.default_max_load_percentage,
|
||||
),
|
||||
dispatch_depth: usize,
|
||||
deferred_removals: std.ArrayList(struct { list: *std.DoublyLinkedList, listener: *Listener }),
|
||||
|
||||
pub fn init(arena: Allocator, page: *Page) EventManager {
|
||||
return .{
|
||||
.page = page,
|
||||
.lookup = .{},
|
||||
.arena = arena,
|
||||
.ignore_list = .{},
|
||||
.list_pool = .init(arena),
|
||||
.listener_pool = .init(arena),
|
||||
.dispatch_depth = 0,
|
||||
.deferred_removals = .{},
|
||||
.has_dom_load_listener = false,
|
||||
};
|
||||
}
|
||||
|
||||
pub const RegisterOptions = struct {
|
||||
once: bool = false,
|
||||
capture: bool = false,
|
||||
passive: bool = false,
|
||||
signal: ?*@import("webapi/AbortSignal.zig") = null,
|
||||
};
|
||||
|
||||
pub const Callback = union(enum) {
|
||||
function: js.Function,
|
||||
object: js.Object,
|
||||
};
|
||||
|
||||
pub fn register(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, opts: RegisterOptions) !void {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.event, "eventManager.register", .{ .type = typ, .capture = opts.capture, .once = opts.once, .target = target.toString() });
|
||||
}
|
||||
|
||||
// If a signal is provided and already aborted, don't register the listener
|
||||
if (opts.signal) |signal| {
|
||||
if (signal.getAborted()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate the type string we'll use in both listener and key
|
||||
const type_string = try String.init(self.arena, typ, .{});
|
||||
|
||||
if (type_string.eql(comptime .wrap("load")) and target._type == .node) {
|
||||
self.has_dom_load_listener = true;
|
||||
}
|
||||
|
||||
const gop = try self.lookup.getOrPut(self.arena, .{
|
||||
.type_string = type_string,
|
||||
.event_target = @intFromPtr(target),
|
||||
});
|
||||
if (gop.found_existing) {
|
||||
// check for duplicate callbacks already registered
|
||||
var node = gop.value_ptr.*.first;
|
||||
while (node) |n| {
|
||||
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
|
||||
const is_duplicate = switch (callback) {
|
||||
.object => |obj| listener.function.eqlObject(obj),
|
||||
.function => |func| listener.function.eqlFunction(func),
|
||||
};
|
||||
if (is_duplicate and listener.capture == opts.capture) {
|
||||
return;
|
||||
}
|
||||
node = n.next;
|
||||
}
|
||||
} else {
|
||||
gop.value_ptr.* = try self.list_pool.create();
|
||||
gop.value_ptr.*.* = .{};
|
||||
}
|
||||
|
||||
const func = switch (callback) {
|
||||
.function => |f| Function{ .value = try f.persist() },
|
||||
.object => |o| Function{ .object = try o.persist() },
|
||||
};
|
||||
|
||||
const listener = try self.listener_pool.create();
|
||||
listener.* = .{
|
||||
.node = .{},
|
||||
.once = opts.once,
|
||||
.capture = opts.capture,
|
||||
.passive = opts.passive,
|
||||
.function = func,
|
||||
.signal = opts.signal,
|
||||
.typ = type_string,
|
||||
};
|
||||
// append the listener to the list of listeners for this target
|
||||
gop.value_ptr.*.append(&listener.node);
|
||||
|
||||
// Track load listeners for script execution ignore list
|
||||
if (type_string.eql(comptime .wrap("load"))) {
|
||||
try self.ignore_list.append(self.arena, listener);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(self: *EventManager, target: *EventTarget, typ: []const u8, callback: Callback, use_capture: bool) void {
|
||||
const list = self.lookup.get(.{
|
||||
.type_string = .wrap(typ),
|
||||
.event_target = @intFromPtr(target),
|
||||
}) orelse return;
|
||||
if (findListener(list, callback, use_capture)) |listener| {
|
||||
self.removeListener(list, listener);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clearIgnoreList(self: *EventManager) void {
|
||||
self.ignore_list.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// Dispatching can be recursive from the compiler's point of view, so we need to
|
||||
// give it an explicit error set so that other parts of the code can use and
|
||||
// inferred error.
|
||||
const DispatchError = error{
|
||||
OutOfMemory,
|
||||
StringTooLarge,
|
||||
JSExecCallback,
|
||||
CompilationError,
|
||||
ExecutionError,
|
||||
JsException,
|
||||
};
|
||||
|
||||
pub const DispatchOpts = struct {
|
||||
// A "load" event triggered by a script (in ScriptManager) should not trigger
|
||||
// a "load" listener added within that script. Therefore, any "load" listener
|
||||
// that we add go into an ignore list until after the script finishes executing.
|
||||
// The ignore list is only checked when apply_ignore == true, which is only
|
||||
// set by the ScriptManager when raising the script's "load" event.
|
||||
apply_ignore: bool = false,
|
||||
};
|
||||
|
||||
pub fn dispatch(self: *EventManager, target: *EventTarget, event: *Event) DispatchError!void {
|
||||
return self.dispatchOpts(target, event, .{});
|
||||
}
|
||||
|
||||
pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, comptime opts: DispatchOpts) DispatchError!void {
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, self.page._session);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
|
||||
}
|
||||
|
||||
switch (target._type) {
|
||||
.node => |node| try self.dispatchNode(node, event, opts),
|
||||
else => try self.dispatchDirect(target, event, null, .{ .context = "dispatch" }),
|
||||
}
|
||||
}
|
||||
|
||||
// There are a lot of events that can be attached via addEventListener or as
|
||||
// a property, like the XHR events, or window.onload. You might think that the
|
||||
// property is just a shortcut for calling addEventListener, but they are distinct.
|
||||
// An event set via property cannot be removed by removeEventListener. If you
|
||||
// set both the property and add a listener, they both execute.
|
||||
const DispatchDirectOptions = struct {
|
||||
context: []const u8,
|
||||
inject_target: bool = true,
|
||||
};
|
||||
|
||||
// Direct dispatch for non-DOM targets (Window, XHR, AbortSignal) or DOM nodes with
|
||||
// property handlers. No propagation - just calls the handler and registered listeners.
|
||||
// Handler can be: null, ?js.Function.Global, ?js.Function.Temp, or js.Function
|
||||
pub fn dispatchDirect(self: *EventManager, target: *EventTarget, event: *Event, handler: anytype, comptime opts: DispatchDirectOptions) !void {
|
||||
const page = self.page;
|
||||
|
||||
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||
const window = page.window;
|
||||
const prev_event = window._current_event;
|
||||
window._current_event = event;
|
||||
defer window._current_event = prev_event;
|
||||
|
||||
event.acquireRef();
|
||||
defer event.deinit(false, page._session);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.event, "dispatchDirect", .{ .type = event._type_string, .context = opts.context });
|
||||
}
|
||||
|
||||
if (comptime opts.inject_target) {
|
||||
event._target = target;
|
||||
event._dispatch_target = target; // Store original target for composedPath()
|
||||
}
|
||||
|
||||
var was_dispatched = false;
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer {
|
||||
ls.local.runMicrotasks();
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
if (getFunction(handler, &ls.local)) |func| {
|
||||
event._current_target = target;
|
||||
if (func.callWithThis(void, target, .{event})) {
|
||||
was_dispatched = true;
|
||||
} else |err| {
|
||||
// a non-JS error
|
||||
log.warn(.event, opts.context, .{ .err = err });
|
||||
}
|
||||
}
|
||||
|
||||
// listeners reigstered via addEventListener
|
||||
const list = self.lookup.get(.{
|
||||
.event_target = @intFromPtr(target),
|
||||
.type_string = event._type_string,
|
||||
}) orelse return;
|
||||
|
||||
// This is a slightly simplified version of what you'll find in dispatchPhase
|
||||
// It is simpler because, for direct dispatching, we know there's no ancestors
|
||||
// and only the single target phase.
|
||||
|
||||
// Track dispatch depth for deferred removal
|
||||
self.dispatch_depth += 1;
|
||||
defer {
|
||||
const dispatch_depth = self.dispatch_depth;
|
||||
// Only destroy deferred listeners when we exit the outermost dispatch
|
||||
if (dispatch_depth == 1) {
|
||||
for (self.deferred_removals.items) |removal| {
|
||||
removal.list.remove(&removal.listener.node);
|
||||
self.listener_pool.destroy(removal.listener);
|
||||
}
|
||||
self.deferred_removals.clearRetainingCapacity();
|
||||
} else {
|
||||
self.dispatch_depth = dispatch_depth - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
|
||||
const last_node = list.last orelse return;
|
||||
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
|
||||
|
||||
// Iterate through the list, stopping after we've encountered the last_listener
|
||||
var node = list.first;
|
||||
var is_done = false;
|
||||
while (node) |n| {
|
||||
if (is_done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
|
||||
is_done = (listener == last_listener);
|
||||
node = n.next;
|
||||
|
||||
// Skip removed listeners
|
||||
if (listener.removed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the listener has an aborted signal, remove it and skip
|
||||
if (listener.signal) |signal| {
|
||||
if (signal.getAborted()) {
|
||||
self.removeListener(list, listener);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
|
||||
if (listener.once) {
|
||||
self.removeListener(list, listener);
|
||||
}
|
||||
|
||||
was_dispatched = true;
|
||||
event._current_target = target;
|
||||
|
||||
switch (listener.function) {
|
||||
.value => |value| try ls.toLocal(value).callWithThis(void, target, .{event}),
|
||||
.string => |string| {
|
||||
const str = try page.call_arena.dupeZ(u8, string.str());
|
||||
try ls.local.eval(str, null);
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = ls.toLocal(obj_global);
|
||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||
try handleEvent.callWithThis(void, obj, .{event});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if (event._stop_immediate_propagation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getFunction(handler: anytype, local: *const js.Local) ?js.Function {
|
||||
const T = @TypeOf(handler);
|
||||
const ti = @typeInfo(T);
|
||||
|
||||
if (ti == .null) {
|
||||
return null;
|
||||
}
|
||||
if (ti == .optional) {
|
||||
return getFunction(handler orelse return null, local);
|
||||
}
|
||||
return switch (T) {
|
||||
js.Function => handler,
|
||||
js.Function.Temp => local.toLocal(handler),
|
||||
js.Function.Global => local.toLocal(handler),
|
||||
else => @compileError("handler must be null or \\??js.Function(\\.(Temp|Global))?"),
|
||||
};
|
||||
}
|
||||
|
||||
/// Check if there are any listeners for a direct dispatch (non-DOM target).
|
||||
/// Use this to avoid creating an event when there are no listeners.
|
||||
pub fn hasDirectListeners(self: *EventManager, target: *EventTarget, typ: []const u8, handler: anytype) bool {
|
||||
if (hasHandler(handler)) {
|
||||
return true;
|
||||
}
|
||||
return self.lookup.get(.{
|
||||
.event_target = @intFromPtr(target),
|
||||
.type_string = .wrap(typ),
|
||||
}) != null;
|
||||
}
|
||||
|
||||
fn hasHandler(handler: anytype) bool {
|
||||
const ti = @typeInfo(@TypeOf(handler));
|
||||
if (ti == .null) {
|
||||
return false;
|
||||
}
|
||||
if (ti == .optional) {
|
||||
return handler != null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn dispatchNode(self: *EventManager, target: *Node, event: *Event, comptime opts: DispatchOpts) !void {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
{
|
||||
const et = target.asEventTarget();
|
||||
event._target = et;
|
||||
event._dispatch_target = et; // Store original target for composedPath()
|
||||
}
|
||||
|
||||
const page = self.page;
|
||||
|
||||
// Set window.event to the currently dispatching event (WHATWG spec)
|
||||
const window = page.window;
|
||||
const prev_event = window._current_event;
|
||||
window._current_event = event;
|
||||
defer window._current_event = prev_event;
|
||||
|
||||
var was_handled = false;
|
||||
|
||||
// Create a single scope for all event handlers in this dispatch.
|
||||
// This ensures function handles passed to queueMicrotask remain valid
|
||||
// throughout the entire dispatch, preventing crashes when microtasks run.
|
||||
var ls: js.Local.Scope = undefined;
|
||||
page.js.localScope(&ls);
|
||||
defer {
|
||||
if (was_handled) {
|
||||
ls.local.runMicrotasks();
|
||||
}
|
||||
ls.deinit();
|
||||
}
|
||||
|
||||
const activation_state = ActivationState.create(event, target, page);
|
||||
|
||||
// Defer runs even on early return - ensures event phase is reset
|
||||
// and default actions execute (unless prevented)
|
||||
defer {
|
||||
event._event_phase = .none;
|
||||
event._stop_propagation = false;
|
||||
event._stop_immediate_propagation = false;
|
||||
// Handle checkbox/radio activation rollback or commit
|
||||
if (activation_state) |state| {
|
||||
state.restore(event, page);
|
||||
}
|
||||
|
||||
// Execute default action if not prevented
|
||||
if (event._prevent_default) {
|
||||
// can't return in a defer (╯°□°)╯︵ ┻━┻
|
||||
} else if (event._type_string.eql(comptime .wrap("click"))) {
|
||||
page.handleClick(target) catch |err| {
|
||||
log.warn(.event, "page.click", .{ .err = err });
|
||||
};
|
||||
} else if (event._type_string.eql(comptime .wrap("keydown"))) {
|
||||
page.handleKeydown(target, event) catch |err| {
|
||||
log.warn(.event, "page.keydown", .{ .err = err });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var path_len: usize = 0;
|
||||
var path_buffer: [128]*EventTarget = undefined;
|
||||
|
||||
var node: ?*Node = target;
|
||||
while (node) |n| {
|
||||
if (path_len >= path_buffer.len) break;
|
||||
path_buffer[path_len] = n.asEventTarget();
|
||||
path_len += 1;
|
||||
|
||||
// Check if this node is a shadow root
|
||||
if (n.is(ShadowRoot)) |shadow| {
|
||||
event._needs_retargeting = true;
|
||||
|
||||
// If event is not composed, stop at shadow boundary
|
||||
if (!event._composed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Otherwise, jump to the shadow host and continue
|
||||
node = shadow._host.asNode();
|
||||
continue;
|
||||
}
|
||||
|
||||
node = n._parent;
|
||||
}
|
||||
|
||||
// Even though the window isn't part of the DOM, most events propagate
|
||||
// through it in the capture phase (unless we stopped at a shadow boundary)
|
||||
// The only explicit exception is "load"
|
||||
if (event._type_string.eql(comptime .wrap("load")) == false) {
|
||||
if (path_len < path_buffer.len) {
|
||||
path_buffer[path_len] = page.window.asEventTarget();
|
||||
path_len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const path = path_buffer[0..path_len];
|
||||
|
||||
// Phase 1: Capturing phase (root → target, excluding target)
|
||||
// This happens for all events, regardless of bubbling
|
||||
event._event_phase = .capturing_phase;
|
||||
var i: usize = path_len;
|
||||
while (i > 1) {
|
||||
i -= 1;
|
||||
if (event._stop_propagation) return;
|
||||
const current_target = path[i];
|
||||
if (self.lookup.get(.{
|
||||
.event_target = @intFromPtr(current_target),
|
||||
.type_string = event._type_string,
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(true, opts));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: At target
|
||||
if (event._stop_propagation) return;
|
||||
event._event_phase = .at_target;
|
||||
const target_et = target.asEventTarget();
|
||||
|
||||
blk: {
|
||||
// Get inline handler (e.g., onclick property) for this target
|
||||
if (self.getInlineHandler(target_et, event)) |inline_handler| {
|
||||
was_handled = true;
|
||||
event._current_target = target_et;
|
||||
|
||||
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
|
||||
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event._stop_immediate_propagation) {
|
||||
break :blk;
|
||||
}
|
||||
}
|
||||
|
||||
if (self.lookup.get(.{
|
||||
.type_string = event._type_string,
|
||||
.event_target = @intFromPtr(target_et),
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, target_et, event, &was_handled, &ls.local, comptime .init(null, opts));
|
||||
if (event._stop_propagation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Bubbling phase (target → root, excluding target)
|
||||
// This only happens if the event bubbles
|
||||
if (event._bubbles) {
|
||||
event._event_phase = .bubbling_phase;
|
||||
for (path[1..]) |current_target| {
|
||||
if (event._stop_propagation) break;
|
||||
if (self.lookup.get(.{
|
||||
.type_string = event._type_string,
|
||||
.event_target = @intFromPtr(current_target),
|
||||
})) |list| {
|
||||
try self.dispatchPhase(list, current_target, event, &was_handled, &ls.local, comptime .init(false, opts));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DispatchPhaseOpts = struct {
|
||||
capture_only: ?bool = null,
|
||||
apply_ignore: bool = false,
|
||||
|
||||
fn init(capture_only: ?bool, opts: DispatchOpts) DispatchPhaseOpts {
|
||||
return .{
|
||||
.capture_only = capture_only,
|
||||
.apply_ignore = opts.apply_ignore,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
fn dispatchPhase(self: *EventManager, list: *std.DoublyLinkedList, current_target: *EventTarget, event: *Event, was_handled: *bool, local: *const js.Local, comptime opts: DispatchPhaseOpts) !void {
|
||||
const page = self.page;
|
||||
|
||||
// Track dispatch depth for deferred removal
|
||||
self.dispatch_depth += 1;
|
||||
defer {
|
||||
const dispatch_depth = self.dispatch_depth;
|
||||
// Only destroy deferred listeners when we exit the outermost dispatch
|
||||
if (dispatch_depth == 1) {
|
||||
for (self.deferred_removals.items) |removal| {
|
||||
removal.list.remove(&removal.listener.node);
|
||||
self.listener_pool.destroy(removal.listener);
|
||||
}
|
||||
self.deferred_removals.clearRetainingCapacity();
|
||||
} else {
|
||||
self.dispatch_depth = dispatch_depth - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Use the last listener in the list as sentinel - listeners added during dispatch will be after it
|
||||
const last_node = list.last orelse return;
|
||||
const last_listener: *Listener = @alignCast(@fieldParentPtr("node", last_node));
|
||||
|
||||
// Iterate through the list, stopping after we've encountered the last_listener
|
||||
var node = list.first;
|
||||
var is_done = false;
|
||||
node_loop: while (node) |n| {
|
||||
if (is_done) {
|
||||
break;
|
||||
}
|
||||
|
||||
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
|
||||
is_done = (listener == last_listener);
|
||||
node = n.next;
|
||||
|
||||
// Skip non-matching listeners
|
||||
if (comptime opts.capture_only) |capture| {
|
||||
if (listener.capture != capture) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip removed listeners
|
||||
if (listener.removed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the listener has an aborted signal, remove it and skip
|
||||
if (listener.signal) |signal| {
|
||||
if (signal.getAborted()) {
|
||||
self.removeListener(list, listener);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime opts.apply_ignore) {
|
||||
for (self.ignore_list.items) |ignored| {
|
||||
if (ignored == listener) {
|
||||
continue :node_loop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove "once" listeners BEFORE calling them so nested dispatches don't see them
|
||||
if (listener.once) {
|
||||
self.removeListener(list, listener);
|
||||
}
|
||||
|
||||
was_handled.* = true;
|
||||
event._current_target = current_target;
|
||||
|
||||
// Compute adjusted target for shadow DOM retargeting (only if needed)
|
||||
const original_target = event._target;
|
||||
if (event._needs_retargeting) {
|
||||
event._target = getAdjustedTarget(original_target, current_target);
|
||||
}
|
||||
|
||||
switch (listener.function) {
|
||||
.value => |value| try local.toLocal(value).callWithThis(void, current_target, .{event}),
|
||||
.string => |string| {
|
||||
const str = try page.call_arena.dupeZ(u8, string.str());
|
||||
try local.eval(str, null);
|
||||
},
|
||||
.object => |obj_global| {
|
||||
const obj = local.toLocal(obj_global);
|
||||
if (try obj.getFunction("handleEvent")) |handleEvent| {
|
||||
try handleEvent.callWithThis(void, obj, .{event});
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Restore original target (only if we changed it)
|
||||
if (event._needs_retargeting) {
|
||||
event._target = original_target;
|
||||
}
|
||||
|
||||
if (event._stop_immediate_propagation) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
|
||||
const global_event_handlers = @import("webapi/global_event_handlers.zig");
|
||||
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
|
||||
|
||||
// Look up the inline handler for this target
|
||||
const html_element = switch (target._type) {
|
||||
.node => |n| n.is(Element.Html) orelse return null,
|
||||
else => return null,
|
||||
};
|
||||
|
||||
return html_element.getAttributeFunction(handler_type, self.page) catch |err| {
|
||||
log.warn(.event, "inline html callback", .{ .type = handler_type, .err = err });
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
|
||||
// If we're in a dispatch, defer removal to avoid invalidating iteration
|
||||
if (self.dispatch_depth > 0) {
|
||||
listener.removed = true;
|
||||
self.deferred_removals.append(self.arena, .{ .list = list, .listener = listener }) catch unreachable;
|
||||
} else {
|
||||
// Outside dispatch, remove immediately
|
||||
list.remove(&listener.node);
|
||||
self.listener_pool.destroy(listener);
|
||||
}
|
||||
}
|
||||
|
||||
fn findListener(list: *const std.DoublyLinkedList, callback: Callback, capture: bool) ?*Listener {
|
||||
var node = list.first;
|
||||
while (node) |n| {
|
||||
node = n.next;
|
||||
const listener: *Listener = @alignCast(@fieldParentPtr("node", n));
|
||||
const matches = switch (callback) {
|
||||
.object => |obj| listener.function.eqlObject(obj),
|
||||
.function => |func| listener.function.eqlFunction(func),
|
||||
};
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
if (listener.capture != capture) {
|
||||
continue;
|
||||
}
|
||||
return listener;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const Listener = struct {
|
||||
typ: String,
|
||||
once: bool,
|
||||
capture: bool,
|
||||
passive: bool,
|
||||
function: Function,
|
||||
signal: ?*@import("webapi/AbortSignal.zig") = null,
|
||||
node: std.DoublyLinkedList.Node,
|
||||
removed: bool = false,
|
||||
};
|
||||
|
||||
const Function = union(enum) {
|
||||
value: js.Function.Global,
|
||||
string: String,
|
||||
object: js.Object.Global,
|
||||
|
||||
fn eqlFunction(self: Function, func: js.Function) bool {
|
||||
return switch (self) {
|
||||
.value => |v| v.isEqual(func),
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn eqlObject(self: Function, obj: js.Object) bool {
|
||||
return switch (self) {
|
||||
.object => |o| return o.isEqual(obj),
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Computes the adjusted target for shadow DOM event retargeting
|
||||
// Returns the lowest shadow-including ancestor of original_target that is
|
||||
// also an ancestor-or-self of current_target
|
||||
fn getAdjustedTarget(original_target: ?*EventTarget, current_target: *EventTarget) ?*EventTarget {
|
||||
const ShadowRoot = @import("webapi/ShadowRoot.zig");
|
||||
|
||||
const orig_node = switch ((original_target orelse return null)._type) {
|
||||
.node => |n| n,
|
||||
else => return original_target,
|
||||
};
|
||||
const curr_node = switch (current_target._type) {
|
||||
.node => |n| n,
|
||||
else => return original_target,
|
||||
};
|
||||
|
||||
// Walk up from original target, checking if we can reach current target
|
||||
var node: ?*Node = orig_node;
|
||||
while (node) |n| {
|
||||
// Check if current_target is an ancestor of n (or n itself)
|
||||
if (isAncestorOrSelf(curr_node, n)) {
|
||||
return n.asEventTarget();
|
||||
}
|
||||
|
||||
// Cross shadow boundary if needed
|
||||
if (n.is(ShadowRoot)) |shadow| {
|
||||
node = shadow._host.asNode();
|
||||
continue;
|
||||
}
|
||||
|
||||
node = n._parent;
|
||||
}
|
||||
|
||||
return original_target;
|
||||
}
|
||||
|
||||
// Check if ancestor is an ancestor of (or the same as) node
|
||||
// WITHOUT crossing shadow boundaries (just regular DOM tree)
|
||||
fn isAncestorOrSelf(ancestor: *Node, node: *Node) bool {
|
||||
if (ancestor == node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var current: ?*Node = node._parent;
|
||||
while (current) |n| {
|
||||
if (n == ancestor) {
|
||||
return true;
|
||||
}
|
||||
current = n._parent;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handles the default action for clicking on input checked/radio. Maybe this
|
||||
// could be generalized if needed, but I'm not sure. This wasn't obvious to me
|
||||
// but when an input is clicked, it's important to think about both the intent
|
||||
// and the actual result. Imagine you have an unchecked checkbox. When clicked,
|
||||
// the checkbox immediately becomes checked, and event handlers see this "checked"
|
||||
// intent. But a listener can preventDefault() in which case the check we did at
|
||||
// the start will be undone.
|
||||
// This is a bit more complicated for radio buttons, as the checking/unchecking
|
||||
// and the rollback can impact a different radio input. So if you "check" a radio
|
||||
// the intent is that it becomes checked and whatever was checked before becomes
|
||||
// unchecked, so that if you have to rollback (because of a preventDefault())
|
||||
// then both inputs have to revert to their original values.
|
||||
const ActivationState = struct {
|
||||
old_checked: bool,
|
||||
input: *Element.Html.Input,
|
||||
previously_checked_radio: ?*Input,
|
||||
|
||||
const Input = Element.Html.Input;
|
||||
|
||||
fn create(event: *const Event, target: *Node, page: *Page) ?ActivationState {
|
||||
if (event._type_string.eql(comptime .wrap("click")) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const input = target.is(Element.Html.Input) orelse return null;
|
||||
if (input._input_type != .checkbox and input._input_type != .radio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const old_checked = input._checked;
|
||||
var previously_checked_radio: ?*Element.Html.Input = null;
|
||||
|
||||
// For radio buttons, find the currently checked radio in the group
|
||||
if (input._input_type == .radio and !old_checked) {
|
||||
previously_checked_radio = try findCheckedRadioInGroup(input, page);
|
||||
}
|
||||
|
||||
// Toggle checkbox or check radio (which unchecks others in group)
|
||||
const new_checked = if (input._input_type == .checkbox) !old_checked else true;
|
||||
try input.setChecked(new_checked, page);
|
||||
|
||||
return .{
|
||||
.input = input,
|
||||
.old_checked = old_checked,
|
||||
.previously_checked_radio = previously_checked_radio,
|
||||
};
|
||||
}
|
||||
|
||||
fn restore(self: *const ActivationState, event: *const Event, page: *Page) void {
|
||||
const input = self.input;
|
||||
if (event._prevent_default) {
|
||||
// Rollback: restore previous state
|
||||
input._checked = self.old_checked;
|
||||
input._checked_dirty = true;
|
||||
if (self.previously_checked_radio) |prev_radio| {
|
||||
prev_radio._checked = true;
|
||||
prev_radio._checked_dirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Commit: fire input and change events only if state actually changed
|
||||
// and the element is connected to a document (detached elements don't fire).
|
||||
// For checkboxes, state always changes. For radios, only if was unchecked.
|
||||
const state_changed = (input._input_type == .checkbox) or !self.old_checked;
|
||||
if (state_changed and input.asElement().asNode().isConnected()) {
|
||||
fireEvent(page, input, "input") catch |err| {
|
||||
log.warn(.event, "input event", .{ .err = err });
|
||||
};
|
||||
fireEvent(page, input, "change") catch |err| {
|
||||
log.warn(.event, "change event", .{ .err = err });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn findCheckedRadioInGroup(input: *Input, page: *Page) !?*Input {
|
||||
const elem = input.asElement();
|
||||
|
||||
const name = elem.getAttributeSafe(comptime .wrap("name")) orelse return null;
|
||||
if (name.len == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const form = input.getForm(page);
|
||||
|
||||
// Walk from the root of the tree containing this element
|
||||
// This handles both document-attached and orphaned elements
|
||||
const root = elem.asNode().getRootNode(null);
|
||||
|
||||
const TreeWalker = @import("webapi/TreeWalker.zig");
|
||||
var walker = TreeWalker.Full.init(root, .{});
|
||||
|
||||
while (walker.next()) |node| {
|
||||
const other_element = node.is(Element) orelse continue;
|
||||
const other_input = other_element.is(Input) orelse continue;
|
||||
|
||||
if (other_input._input_type != .radio) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip the input we're checking from
|
||||
if (other_input == input) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const other_name = other_element.getAttributeSafe(comptime .wrap("name")) orelse continue;
|
||||
if (!std.mem.eql(u8, name, other_name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if same form context
|
||||
const other_form = other_input.getForm(page);
|
||||
if (form) |f| {
|
||||
const of = other_form orelse continue;
|
||||
if (f != of) {
|
||||
continue; // Different forms
|
||||
}
|
||||
} else if (other_form != null) {
|
||||
continue; // form is null but other has a form
|
||||
}
|
||||
|
||||
if (other_input._checked) {
|
||||
return other_input;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fire input or change event
|
||||
fn fireEvent(page: *Page, input: *Input, comptime typ: []const u8) !void {
|
||||
const event = try Event.initTrusted(comptime .wrap(typ), .{
|
||||
.bubbles = true,
|
||||
.cancelable = false,
|
||||
}, page);
|
||||
|
||||
const target = input.asElement().asEventTarget();
|
||||
try page._event_manager.dispatch(target, event);
|
||||
}
|
||||
};
|
||||
@@ -1,462 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const reflect = @import("reflect.zig");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const String = @import("../string.zig").String;
|
||||
|
||||
const SlabAllocator = @import("../slab.zig").SlabAllocator;
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const UIEvent = @import("webapi/event/UIEvent.zig");
|
||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Document = @import("webapi/Document.zig");
|
||||
const EventTarget = @import("webapi/EventTarget.zig");
|
||||
const XMLHttpRequestEventTarget = @import("webapi/net/XMLHttpRequestEventTarget.zig");
|
||||
const Blob = @import("webapi/Blob.zig");
|
||||
const AbstractRange = @import("webapi/AbstractRange.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
const assert = std.debug.assert;
|
||||
|
||||
// Shared across all frames of a Page.
|
||||
const Factory = @This();
|
||||
|
||||
_arena: Allocator,
|
||||
_slab: SlabAllocator,
|
||||
|
||||
pub fn init(arena: Allocator) Factory {
|
||||
return .{
|
||||
._arena = arena,
|
||||
._slab = SlabAllocator.init(arena, 128),
|
||||
};
|
||||
}
|
||||
|
||||
// this is a root object
|
||||
pub fn eventTarget(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
return self.eventTargetWithAllocator(self._slab.allocator(), child);
|
||||
}
|
||||
|
||||
pub fn eventTargetWithAllocator(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ EventTarget, @TypeOf(child) },
|
||||
).allocate(allocator);
|
||||
|
||||
const event_ptr = chain.get(0);
|
||||
event_ptr.* = .{
|
||||
._type = unionInit(EventTarget.Type, chain.get(1)),
|
||||
};
|
||||
chain.setLeaf(1, child);
|
||||
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn standaloneEventTarget(self: *Factory, child: anytype) !*EventTarget {
|
||||
const allocator = self._slab.allocator();
|
||||
const et = try allocator.create(EventTarget);
|
||||
et.* = .{ ._type = unionInit(EventTarget.Type, child) };
|
||||
return et;
|
||||
}
|
||||
|
||||
// this is a root object
|
||||
pub fn event(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ Event, @TypeOf(child) },
|
||||
).allocate(arena);
|
||||
|
||||
// Special case: Event has a _type_string field, so we need manual setup
|
||||
const event_ptr = chain.get(0);
|
||||
event_ptr.* = try eventInit(arena, typ, chain.get(1));
|
||||
chain.setLeaf(1, child);
|
||||
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn uiEvent(_: *const Factory, arena: Allocator, typ: String, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ Event, UIEvent, @TypeOf(child) },
|
||||
).allocate(arena);
|
||||
|
||||
// Special case: Event has a _type_string field, so we need manual setup
|
||||
const event_ptr = chain.get(0);
|
||||
event_ptr.* = try eventInit(arena, typ, chain.get(1));
|
||||
chain.setMiddle(1, UIEvent.Type);
|
||||
chain.setLeaf(2, child);
|
||||
|
||||
return chain.get(2);
|
||||
}
|
||||
|
||||
pub fn mouseEvent(_: *const Factory, arena: Allocator, typ: String, mouse: MouseEvent, child: anytype) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(
|
||||
&.{ Event, UIEvent, MouseEvent, @TypeOf(child) },
|
||||
).allocate(arena);
|
||||
|
||||
// Special case: Event has a _type_string field, so we need manual setup
|
||||
const event_ptr = chain.get(0);
|
||||
event_ptr.* = try eventInit(arena, typ, chain.get(1));
|
||||
chain.setMiddle(1, UIEvent.Type);
|
||||
|
||||
// Set MouseEvent with all its fields
|
||||
const mouse_ptr = chain.get(2);
|
||||
mouse_ptr.* = mouse;
|
||||
mouse_ptr._proto = chain.get(1);
|
||||
mouse_ptr._type = unionInit(MouseEvent.Type, chain.get(3));
|
||||
|
||||
chain.setLeaf(3, child);
|
||||
|
||||
return chain.get(3);
|
||||
}
|
||||
|
||||
fn PrototypeChain(comptime types: []const type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
memory: []u8,
|
||||
|
||||
fn totalSize() usize {
|
||||
var size: usize = 0;
|
||||
for (types) |T| {
|
||||
size = std.mem.alignForward(usize, size, @alignOf(T));
|
||||
size += @sizeOf(T);
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
fn maxAlign() std.mem.Alignment {
|
||||
var alignment: std.mem.Alignment = .@"1";
|
||||
|
||||
for (types) |T| {
|
||||
alignment = std.mem.Alignment.max(alignment, std.mem.Alignment.of(T));
|
||||
}
|
||||
|
||||
return alignment;
|
||||
}
|
||||
|
||||
fn getType(comptime index: usize) type {
|
||||
return types[index];
|
||||
}
|
||||
|
||||
fn allocate(allocator: std.mem.Allocator) !Self {
|
||||
const size = comptime Self.totalSize();
|
||||
const alignment = comptime Self.maxAlign();
|
||||
|
||||
const memory = try allocator.alignedAlloc(u8, alignment, size);
|
||||
return .{ .memory = memory };
|
||||
}
|
||||
|
||||
fn get(self: *const Self, comptime index: usize) *getType(index) {
|
||||
var offset: usize = 0;
|
||||
inline for (types, 0..) |T, i| {
|
||||
offset = std.mem.alignForward(usize, offset, @alignOf(T));
|
||||
|
||||
if (i == index) {
|
||||
return @as(*T, @ptrCast(@alignCast(self.memory.ptr + offset)));
|
||||
}
|
||||
offset += @sizeOf(T);
|
||||
}
|
||||
unreachable;
|
||||
}
|
||||
|
||||
fn set(self: *const Self, comptime index: usize, value: getType(index)) void {
|
||||
const ptr = self.get(index);
|
||||
ptr.* = value;
|
||||
}
|
||||
|
||||
fn setRoot(self: *const Self, comptime T: type) void {
|
||||
const ptr = self.get(0);
|
||||
ptr.* = .{ ._type = unionInit(T, self.get(1)) };
|
||||
}
|
||||
|
||||
fn setMiddle(self: *const Self, comptime index: usize, comptime T: type) void {
|
||||
assert(index >= 1);
|
||||
assert(index < types.len);
|
||||
|
||||
const ptr = self.get(index);
|
||||
ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, self.get(index + 1)) };
|
||||
}
|
||||
|
||||
fn setMiddleWithValue(self: *const Self, comptime index: usize, comptime T: type, value: anytype) void {
|
||||
assert(index >= 1);
|
||||
|
||||
const ptr = self.get(index);
|
||||
ptr.* = .{ ._proto = self.get(index - 1), ._type = unionInit(T, value) };
|
||||
}
|
||||
|
||||
fn setLeaf(self: *const Self, comptime index: usize, value: anytype) void {
|
||||
assert(index >= 1);
|
||||
|
||||
const ptr = self.get(index);
|
||||
ptr.* = value;
|
||||
ptr._proto = self.get(index - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn AutoPrototypeChain(comptime types: []const type) type {
|
||||
return struct {
|
||||
fn create(allocator: std.mem.Allocator, leaf_value: anytype) !*@TypeOf(leaf_value) {
|
||||
const chain = try PrototypeChain(types).allocate(allocator);
|
||||
|
||||
const RootType = types[0];
|
||||
chain.setRoot(RootType.Type);
|
||||
|
||||
inline for (1..types.len - 1) |i| {
|
||||
const MiddleType = types[i];
|
||||
chain.setMiddle(i, MiddleType.Type);
|
||||
}
|
||||
|
||||
chain.setLeaf(types.len - 1, leaf_value);
|
||||
return chain.get(types.len - 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn eventInit(arena: Allocator, typ: String, value: anytype) !Event {
|
||||
// Round to 2ms for privacy (browsers do this)
|
||||
const raw_timestamp = @import("../datetime.zig").milliTimestamp(.monotonic);
|
||||
const time_stamp = (raw_timestamp / 2) * 2;
|
||||
|
||||
return .{
|
||||
._rc = 0,
|
||||
._arena = arena,
|
||||
._type = unionInit(Event.Type, value),
|
||||
._type_string = typ,
|
||||
._time_stamp = time_stamp,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn blob(_: *const Factory, arena: Allocator, child: anytype) !*@TypeOf(child) {
|
||||
// Special case: Blob has slice and mime fields, so we need manual setup
|
||||
const chain = try PrototypeChain(
|
||||
&.{ Blob, @TypeOf(child) },
|
||||
).allocate(arena);
|
||||
|
||||
const blob_ptr = chain.get(0);
|
||||
blob_ptr.* = .{
|
||||
._arena = arena,
|
||||
._type = unionInit(Blob.Type, chain.get(1)),
|
||||
._slice = "",
|
||||
._mime = "",
|
||||
};
|
||||
chain.setLeaf(1, child);
|
||||
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn abstractRange(_: *const Factory, arena: Allocator, child: anytype, page: *Page) !*@TypeOf(child) {
|
||||
const chain = try PrototypeChain(&.{ AbstractRange, @TypeOf(child) }).allocate(arena);
|
||||
|
||||
const doc = page.document.asNode();
|
||||
const abstract_range = chain.get(0);
|
||||
abstract_range.* = AbstractRange{
|
||||
._rc = 0,
|
||||
._arena = arena,
|
||||
._page_id = page.id,
|
||||
._type = unionInit(AbstractRange.Type, chain.get(1)),
|
||||
._end_offset = 0,
|
||||
._start_offset = 0,
|
||||
._end_container = doc,
|
||||
._start_container = doc,
|
||||
};
|
||||
chain.setLeaf(1, child);
|
||||
page._live_ranges.append(&abstract_range._range_link);
|
||||
return chain.get(1);
|
||||
}
|
||||
|
||||
pub fn node(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn document(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, Document, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn documentFragment(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, Node.DocumentFragment, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn element(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, Element, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn htmlElement(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Html, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn htmlMediaElement(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Html, Element.Html.Media, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn svgElement(self: *Factory, tag_name: []const u8, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
const ChildT = @TypeOf(child);
|
||||
|
||||
if (ChildT == Element.Svg) {
|
||||
return self.element(child);
|
||||
}
|
||||
|
||||
const chain = try PrototypeChain(
|
||||
&.{ EventTarget, Node, Element, Element.Svg, ChildT },
|
||||
).allocate(allocator);
|
||||
|
||||
chain.setRoot(EventTarget.Type);
|
||||
chain.setMiddle(1, Node.Type);
|
||||
chain.setMiddle(2, Element.Type);
|
||||
|
||||
// will never allocate, can't fail
|
||||
const tag_name_str = String.init(self._arena, tag_name, .{}) catch unreachable;
|
||||
|
||||
// Manually set Element.Svg with the tag_name
|
||||
chain.set(3, .{
|
||||
._proto = chain.get(2),
|
||||
._tag_name = tag_name_str,
|
||||
._type = unionInit(Element.Svg.Type, chain.get(4)),
|
||||
});
|
||||
|
||||
chain.setLeaf(4, child);
|
||||
return chain.get(4);
|
||||
}
|
||||
|
||||
pub fn xhrEventTarget(_: *const Factory, allocator: Allocator, child: anytype) !*@TypeOf(child) {
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, XMLHttpRequestEventTarget, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn textTrackCue(self: *Factory, child: anytype) !*@TypeOf(child) {
|
||||
const allocator = self._slab.allocator();
|
||||
const TextTrackCue = @import("webapi/media/TextTrackCue.zig");
|
||||
|
||||
return try AutoPrototypeChain(
|
||||
&.{ EventTarget, TextTrackCue, @TypeOf(child) },
|
||||
).create(allocator, child);
|
||||
}
|
||||
|
||||
pub fn destroy(self: *Factory, value: anytype) void {
|
||||
const S = reflect.Struct(@TypeOf(value));
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
// We should always destroy from the leaf down.
|
||||
if (@hasDecl(S, "_prototype_root")) {
|
||||
// A Event{._type == .generic} (or any other similar types)
|
||||
// _should_ be destoyed directly. The _type = .generic is a pseudo
|
||||
// child
|
||||
if (S != Event or value._type != .generic) {
|
||||
log.fatal(.bug, "factory.destroy.event", .{ .type = @typeName(S) });
|
||||
unreachable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (comptime @hasField(S, "_proto")) {
|
||||
self.destroyChain(value, 0, std.mem.Alignment.@"1");
|
||||
} else {
|
||||
self.destroyStandalone(value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn destroyStandalone(self: *Factory, value: anytype) void {
|
||||
const allocator = self._slab.allocator();
|
||||
allocator.destroy(value);
|
||||
}
|
||||
|
||||
fn destroyChain(
|
||||
self: *Factory,
|
||||
value: anytype,
|
||||
old_size: usize,
|
||||
old_align: std.mem.Alignment,
|
||||
) void {
|
||||
const S = reflect.Struct(@TypeOf(value));
|
||||
const allocator = self._slab.allocator();
|
||||
|
||||
// aligns the old size to the alignment of this element
|
||||
const current_size = std.mem.alignForward(usize, old_size, @alignOf(S));
|
||||
const new_size = current_size + @sizeOf(S);
|
||||
const new_align = std.mem.Alignment.max(old_align, std.mem.Alignment.of(S));
|
||||
|
||||
if (@hasField(S, "_proto")) {
|
||||
self.destroyChain(value._proto, new_size, new_align);
|
||||
} else {
|
||||
// no proto so this is the head of the chain.
|
||||
// we use this as the ptr to the start of the chain.
|
||||
// and we have summed up the length.
|
||||
assert(@hasDecl(S, "_prototype_root"));
|
||||
|
||||
const memory_ptr: [*]u8 = @ptrCast(@constCast(value));
|
||||
const len = std.mem.alignForward(usize, new_size, new_align.toByteUnits());
|
||||
allocator.rawFree(memory_ptr[0..len], new_align, @returnAddress());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn createT(self: *Factory, comptime T: type) !*T {
|
||||
const allocator = self._slab.allocator();
|
||||
return try allocator.create(T);
|
||||
}
|
||||
|
||||
pub fn create(self: *Factory, value: anytype) !*@TypeOf(value) {
|
||||
const ptr = try self.createT(@TypeOf(value));
|
||||
ptr.* = value;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
fn unionInit(comptime T: type, value: anytype) T {
|
||||
const V = @TypeOf(value);
|
||||
const field_name = comptime unionFieldName(T, V);
|
||||
return @unionInit(T, field_name, value);
|
||||
}
|
||||
|
||||
// There can be friction between comptime and runtime. Comptime has to
|
||||
// account for all possible types, even if some runtime flow makes certain
|
||||
// cases impossible. At runtime, we always call `unionFieldName` with the
|
||||
// correct struct or pointer type. But at comptime time, `unionFieldName`
|
||||
// is called with both variants (S and *S). So we use reflect.Struct().
|
||||
// This only works because we never have a union with a field S and another
|
||||
// field *S.
|
||||
fn unionFieldName(comptime T: type, comptime V: type) []const u8 {
|
||||
inline for (@typeInfo(T).@"union".fields) |field| {
|
||||
if (reflect.Struct(field.type) == reflect.Struct(V)) {
|
||||
return field.name;
|
||||
}
|
||||
}
|
||||
@compileError(@typeName(V) ++ " is not a valid type for " ++ @typeName(T) ++ ".type");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,774 +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 Mime = @This();
|
||||
content_type: ContentType,
|
||||
params: []const u8 = "",
|
||||
// IANA defines max. charset value length as 40.
|
||||
// We keep 41 for null-termination since HTML parser expects in this format.
|
||||
charset: [41]u8 = default_charset,
|
||||
charset_len: usize = default_charset_len,
|
||||
is_default_charset: bool = true,
|
||||
|
||||
/// String "UTF-8" continued by null characters.
|
||||
const default_charset = .{ 'U', 'T', 'F', '-', '8' } ++ .{0} ** 36;
|
||||
const default_charset_len = 5;
|
||||
|
||||
/// Mime with unknown Content-Type, empty params and empty charset.
|
||||
pub const unknown = Mime{ .content_type = .{ .unknown = {} } };
|
||||
|
||||
pub const ContentTypeEnum = enum {
|
||||
text_xml,
|
||||
text_html,
|
||||
text_javascript,
|
||||
text_plain,
|
||||
text_css,
|
||||
image_jpeg,
|
||||
image_gif,
|
||||
image_png,
|
||||
image_webp,
|
||||
application_json,
|
||||
unknown,
|
||||
other,
|
||||
};
|
||||
|
||||
pub const ContentType = union(ContentTypeEnum) {
|
||||
text_xml: void,
|
||||
text_html: void,
|
||||
text_javascript: void,
|
||||
text_plain: void,
|
||||
text_css: void,
|
||||
image_jpeg: void,
|
||||
image_gif: void,
|
||||
image_png: void,
|
||||
image_webp: void,
|
||||
application_json: void,
|
||||
unknown: void,
|
||||
other: struct { type: []const u8, sub_type: []const u8 },
|
||||
};
|
||||
|
||||
pub fn contentTypeString(mime: *const Mime) []const u8 {
|
||||
return switch (mime.content_type) {
|
||||
.text_xml => "text/xml",
|
||||
.text_html => "text/html",
|
||||
.text_javascript => "application/javascript",
|
||||
.text_plain => "text/plain",
|
||||
.text_css => "text/css",
|
||||
.image_jpeg => "image/jpeg",
|
||||
.image_png => "image/png",
|
||||
.image_gif => "image/gif",
|
||||
.image_webp => "image/webp",
|
||||
.application_json => "application/json",
|
||||
else => "",
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the null-terminated charset value.
|
||||
pub fn charsetStringZ(mime: *const Mime) [:0]const u8 {
|
||||
return mime.charset[0..mime.charset_len :0];
|
||||
}
|
||||
|
||||
pub fn charsetString(mime: *const Mime) []const u8 {
|
||||
return mime.charset[0..mime.charset_len];
|
||||
}
|
||||
|
||||
/// Removes quotes of value if quotes are given.
|
||||
///
|
||||
/// Currently we don't validate the charset.
|
||||
/// See section 2.3 Naming Requirements:
|
||||
/// https://datatracker.ietf.org/doc/rfc2978/
|
||||
fn parseCharset(value: []const u8) error{ CharsetTooBig, Invalid }![]const u8 {
|
||||
// Cannot be larger than 40.
|
||||
// https://datatracker.ietf.org/doc/rfc2978/
|
||||
if (value.len > 40) return error.CharsetTooBig;
|
||||
|
||||
// If the first char is a quote, look for a pair.
|
||||
if (value[0] == '"') {
|
||||
if (value.len < 3 or value[value.len - 1] != '"') {
|
||||
return error.Invalid;
|
||||
}
|
||||
|
||||
return value[1 .. value.len - 1];
|
||||
}
|
||||
|
||||
// No quotes.
|
||||
return value;
|
||||
}
|
||||
|
||||
pub fn parse(input: []u8) !Mime {
|
||||
if (input.len > 255) {
|
||||
return error.TooBig;
|
||||
}
|
||||
|
||||
// Zig's trim API is broken. The return type is always `[]const u8`,
|
||||
// even if the input type is `[]u8`. @constCast is safe here.
|
||||
var normalized = @constCast(std.mem.trim(u8, input, &std.ascii.whitespace));
|
||||
_ = std.ascii.lowerString(normalized, normalized);
|
||||
|
||||
const content_type, const type_len = try parseContentType(normalized);
|
||||
if (type_len >= normalized.len) {
|
||||
return .{ .content_type = content_type };
|
||||
}
|
||||
|
||||
const params = trimLeft(normalized[type_len..]);
|
||||
|
||||
var charset: [41]u8 = default_charset;
|
||||
var charset_len: usize = default_charset_len;
|
||||
var has_explicit_charset = false;
|
||||
|
||||
var it = std.mem.splitScalar(u8, params, ';');
|
||||
while (it.next()) |attr| {
|
||||
const i = std.mem.indexOfScalarPos(u8, attr, 0, '=') orelse continue;
|
||||
const name = trimLeft(attr[0..i]);
|
||||
|
||||
const value = trimRight(attr[i + 1 ..]);
|
||||
if (value.len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attribute_name = std.meta.stringToEnum(enum {
|
||||
charset,
|
||||
}, name) orelse continue;
|
||||
|
||||
switch (attribute_name) {
|
||||
.charset => {
|
||||
if (value.len == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const attribute_value = parseCharset(value) catch continue;
|
||||
@memcpy(charset[0..attribute_value.len], attribute_value);
|
||||
// Null-terminate right after attribute value.
|
||||
charset[attribute_value.len] = 0;
|
||||
charset_len = attribute_value.len;
|
||||
has_explicit_charset = true;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return .{
|
||||
.params = params,
|
||||
.charset = charset,
|
||||
.charset_len = charset_len,
|
||||
.content_type = content_type,
|
||||
.is_default_charset = !has_explicit_charset,
|
||||
};
|
||||
}
|
||||
|
||||
/// Prescan the first 1024 bytes of an HTML document for a charset declaration.
|
||||
/// Looks for `<meta charset="X">` and `<meta http-equiv="Content-Type" content="...;charset=X">`.
|
||||
/// Returns the charset value or null if none found.
|
||||
/// See: https://www.w3.org/International/questions/qa-html-encoding-declarations
|
||||
pub fn prescanCharset(html: []const u8) ?[]const u8 {
|
||||
const limit = @min(html.len, 1024);
|
||||
const data = html[0..limit];
|
||||
|
||||
// Scan for <meta tags
|
||||
var pos: usize = 0;
|
||||
while (pos < data.len) {
|
||||
// Find next '<'
|
||||
pos = std.mem.indexOfScalarPos(u8, data, pos, '<') orelse return null;
|
||||
pos += 1;
|
||||
if (pos >= data.len) return null;
|
||||
|
||||
// Check for "meta" (case-insensitive)
|
||||
if (pos + 4 >= data.len) return null;
|
||||
var tag_buf: [4]u8 = undefined;
|
||||
_ = std.ascii.lowerString(&tag_buf, data[pos..][0..4]);
|
||||
if (!std.mem.eql(u8, &tag_buf, "meta")) {
|
||||
continue;
|
||||
}
|
||||
pos += 4;
|
||||
|
||||
// Must be followed by whitespace or end of tag
|
||||
if (pos >= data.len) return null;
|
||||
if (data[pos] != ' ' and data[pos] != '\t' and data[pos] != '\n' and
|
||||
data[pos] != '\r' and data[pos] != '/')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan attributes within this meta tag
|
||||
const tag_end = std.mem.indexOfScalarPos(u8, data, pos, '>') orelse return null;
|
||||
const attrs = data[pos..tag_end];
|
||||
|
||||
// Look for charset= attribute directly
|
||||
if (findAttrValue(attrs, "charset")) |charset| {
|
||||
if (charset.len > 0 and charset.len <= 40) return charset;
|
||||
}
|
||||
|
||||
// Look for http-equiv="content-type" with content="...;charset=X"
|
||||
if (findAttrValue(attrs, "http-equiv")) |he| {
|
||||
if (std.ascii.eqlIgnoreCase(he, "content-type")) {
|
||||
if (findAttrValue(attrs, "content")) |content| {
|
||||
if (extractCharsetFromContentType(content)) |charset| {
|
||||
return charset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pos = tag_end + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn findAttrValue(attrs: []const u8, name: []const u8) ?[]const u8 {
|
||||
var pos: usize = 0;
|
||||
while (pos < attrs.len) {
|
||||
// Skip whitespace
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t' or
|
||||
attrs[pos] == '\n' or attrs[pos] == '\r'))
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
if (pos >= attrs.len) return null;
|
||||
|
||||
// Read attribute name
|
||||
const attr_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != '=' and attrs[pos] != ' ' and
|
||||
attrs[pos] != '\t' and attrs[pos] != '>' and attrs[pos] != '/')
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
const attr_name = attrs[attr_start..pos];
|
||||
|
||||
// Skip whitespace around =
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||
if (pos >= attrs.len or attrs[pos] != '=') {
|
||||
// No '=' found - skip this token. Advance at least one byte to avoid infinite loop.
|
||||
if (pos == attr_start) pos += 1;
|
||||
continue;
|
||||
}
|
||||
pos += 1; // skip '='
|
||||
while (pos < attrs.len and (attrs[pos] == ' ' or attrs[pos] == '\t')) pos += 1;
|
||||
if (pos >= attrs.len) return null;
|
||||
|
||||
// Read attribute value
|
||||
const value = blk: {
|
||||
if (attrs[pos] == '"' or attrs[pos] == '\'') {
|
||||
const quote = attrs[pos];
|
||||
pos += 1;
|
||||
const val_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != quote) pos += 1;
|
||||
const val = attrs[val_start..pos];
|
||||
if (pos < attrs.len) pos += 1; // skip closing quote
|
||||
break :blk val;
|
||||
} else {
|
||||
const val_start = pos;
|
||||
while (pos < attrs.len and attrs[pos] != ' ' and attrs[pos] != '\t' and
|
||||
attrs[pos] != '>' and attrs[pos] != '/')
|
||||
{
|
||||
pos += 1;
|
||||
}
|
||||
break :blk attrs[val_start..pos];
|
||||
}
|
||||
};
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(attr_name, name)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn extractCharsetFromContentType(content: []const u8) ?[]const u8 {
|
||||
var it = std.mem.splitScalar(u8, content, ';');
|
||||
while (it.next()) |part| {
|
||||
const trimmed = std.mem.trimLeft(u8, part, &.{ ' ', '\t' });
|
||||
if (trimmed.len > 8 and std.ascii.eqlIgnoreCase(trimmed[0..8], "charset=")) {
|
||||
const val = std.mem.trim(u8, trimmed[8..], &.{ ' ', '\t', '"', '\'' });
|
||||
if (val.len > 0 and val.len <= 40) return val;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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 = {} },
|
||||
.charset = default_charset,
|
||||
.charset_len = default_charset_len,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFE, 0xFF })) {
|
||||
// UTF-16 big-endian BOM
|
||||
return .{
|
||||
.content_type = .{ .text_plain = {} },
|
||||
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'B', 'E' } ++ .{0} ** 33,
|
||||
.charset_len = 8,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
if (std.mem.startsWith(u8, content, &.{ 0xFF, 0xFE })) {
|
||||
// UTF-16 little-endian BOM
|
||||
return .{
|
||||
.content_type = .{ .text_plain = {} },
|
||||
.charset = .{ 'U', 'T', 'F', '-', '1', '6', 'L', 'E' } ++ .{0} ** 33,
|
||||
.charset_len = 8,
|
||||
.is_default_charset = false,
|
||||
};
|
||||
}
|
||||
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",
|
||||
|
||||
@"image/jpeg",
|
||||
@"image/png",
|
||||
@"image/gif",
|
||||
@"image/webp",
|
||||
|
||||
@"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 = {} },
|
||||
.@"image/jpeg" => .{ .image_jpeg = {} },
|
||||
.@"image/png" => .{ .image_png = {} },
|
||||
.@"image/gif" => .{ .image_gif = {} },
|
||||
.@"image/webp" => .{ .image_webp = {} },
|
||||
.@"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 VALID_CODEPOINTS = blk: {
|
||||
var v: [256]bool = undefined;
|
||||
for (0..256) |i| {
|
||||
v[i] = std.ascii.isAlphanumeric(i);
|
||||
}
|
||||
for ("!#$%&\\*+-.^'_`|~") |b| {
|
||||
v[b] = true;
|
||||
}
|
||||
break :blk v;
|
||||
};
|
||||
|
||||
fn validType(value: []const u8) bool {
|
||||
for (value) |b| {
|
||||
if (VALID_CODEPOINTS[b] == false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
fn trimLeft(s: []const u8) []const u8 {
|
||||
return std.mem.trimLeft(u8, s, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
fn trimRight(s: []const u8) []const u8 {
|
||||
return std.mem.trimRight(u8, s, &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "Mime: invalid" {
|
||||
defer testing.reset();
|
||||
|
||||
const invalids = [_][]const u8{
|
||||
"",
|
||||
"text",
|
||||
"text /html",
|
||||
"text/ html",
|
||||
"text / html",
|
||||
"text/html other",
|
||||
};
|
||||
|
||||
for (invalids) |invalid| {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, invalid);
|
||||
try testing.expectError(error.Invalid, Mime.parse(mutable_input));
|
||||
}
|
||||
}
|
||||
|
||||
test "Mime: malformed parameters are ignored" {
|
||||
defer testing.reset();
|
||||
|
||||
// These should all parse successfully as text/html with malformed params ignored
|
||||
const valid_with_malformed_params = [_][]const u8{
|
||||
"text/html; x",
|
||||
"text/html; x=",
|
||||
"text/html; x= ",
|
||||
"text/html; = ",
|
||||
"text/html;=",
|
||||
"text/html; charset=\"\"",
|
||||
"text/html; charset=\"",
|
||||
"text/html; charset=\"\\",
|
||||
"text/html;\"",
|
||||
};
|
||||
|
||||
for (valid_with_malformed_params) |input| {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, input);
|
||||
const mime = try Mime.parse(mutable_input);
|
||||
try testing.expectEqual(.text_html, std.meta.activeTag(mime.content_type));
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
try expect(.{ .content_type = .{ .image_jpeg = {} } }, "image/jpeg");
|
||||
try expect(.{ .content_type = .{ .image_png = {} } }, "image/png");
|
||||
try expect(.{ .content_type = .{ .image_gif = {} } }, "image/gif");
|
||||
try expect(.{ .content_type = .{ .image_webp = {} } }, "image/webp");
|
||||
}
|
||||
|
||||
test "Mime: parse uncommon" {
|
||||
defer testing.reset();
|
||||
|
||||
const text_csv = Expectation{
|
||||
.content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } },
|
||||
};
|
||||
try expect(text_csv, "text/csv");
|
||||
try expect(text_csv, "text/csv;");
|
||||
try expect(text_csv, " text/csv\t ");
|
||||
try expect(text_csv, " text/csv\t ;");
|
||||
|
||||
try expect(
|
||||
.{ .content_type = .{ .other = .{ .type = "text", .sub_type = "csv" } } },
|
||||
"Text/CSV",
|
||||
);
|
||||
}
|
||||
|
||||
test "Mime: parse charset" {
|
||||
defer testing.reset();
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "utf-8",
|
||||
.params = "charset=utf-8",
|
||||
}, "text/xml; charset=utf-8");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "utf-8",
|
||||
.params = "charset=\"utf-8\"",
|
||||
}, "text/xml;charset=\"UTF-8\"");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_html = {} },
|
||||
.charset = "iso-8859-1",
|
||||
.params = "charset=\"iso-8859-1\"",
|
||||
}, "text/html; charset=\"iso-8859-1\"");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_html = {} },
|
||||
.charset = "iso-8859-1",
|
||||
.params = "charset=\"iso-8859-1\"",
|
||||
}, "text/html; charset=\"ISO-8859-1\"");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_xml = {} },
|
||||
.charset = "custom-non-standard-charset-value",
|
||||
.params = "charset=\"custom-non-standard-charset-value\"",
|
||||
}, "text/xml;charset=\"custom-non-standard-charset-value\"");
|
||||
|
||||
try expect(.{
|
||||
.content_type = .{ .text_html = {} },
|
||||
.charset = "UTF-8",
|
||||
.params = "x=\"",
|
||||
}, "text/html;x=\"");
|
||||
}
|
||||
|
||||
test "Mime: isHTML" {
|
||||
defer testing.reset();
|
||||
|
||||
const assert = struct {
|
||||
fn assert(expected: bool, input: []const u8) !void {
|
||||
const mutable_input = try testing.arena_allocator.dupe(u8, input);
|
||||
var mime = try Mime.parse(mutable_input);
|
||||
try testing.expectEqual(expected, mime.isHTML());
|
||||
}
|
||||
}.assert;
|
||||
try assert(true, "text/html");
|
||||
try assert(true, "text/html;");
|
||||
try assert(true, "text/html; charset=utf-8");
|
||||
try assert(false, "text/htm"); // htm not html
|
||||
try assert(false, "text/plain");
|
||||
try assert(false, "over/9000");
|
||||
}
|
||||
|
||||
test "Mime: sniff" {
|
||||
try testing.expectEqual(null, Mime.sniff(""));
|
||||
try testing.expectEqual(null, Mime.sniff("<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 mime = Mime.sniff(&.{ 0xEF, 0xBB, 0xBF }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-8", mime.charsetString());
|
||||
}
|
||||
|
||||
{
|
||||
const mime = Mime.sniff(&.{ 0xFE, 0xFF }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-16BE", mime.charsetString());
|
||||
}
|
||||
|
||||
{
|
||||
const mime = Mime.sniff(&.{ 0xFF, 0xFE }).?;
|
||||
try testing.expectEqual(.text_plain, std.meta.activeTag(mime.content_type));
|
||||
try testing.expectEqual("UTF-16LE", mime.charsetString());
|
||||
}
|
||||
}
|
||||
|
||||
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(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| {
|
||||
// We remove the null characters for testing purposes here.
|
||||
try testing.expectEqual(ec, actual.charsetString());
|
||||
} else {
|
||||
const m: Mime = .unknown;
|
||||
try testing.expectEqual(m.charsetStringZ(), actual.charsetStringZ());
|
||||
}
|
||||
}
|
||||
|
||||
test "Mime: prescanCharset" {
|
||||
// <meta charset="X">
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<html><head><meta charset=\"utf-8\">").?);
|
||||
try testing.expectEqual("iso-8859-1", Mime.prescanCharset("<html><head><meta charset=\"iso-8859-1\">").?);
|
||||
try testing.expectEqual("shift_jis", Mime.prescanCharset("<meta charset='shift_jis'>").?);
|
||||
|
||||
// Case-insensitive tag matching
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<META charset=\"utf-8\">").?);
|
||||
try testing.expectEqual("utf-8", Mime.prescanCharset("<Meta charset=\"utf-8\">").?);
|
||||
|
||||
// <meta http-equiv="Content-Type" content="text/html; charset=X">
|
||||
try testing.expectEqual(
|
||||
"iso-8859-1",
|
||||
Mime.prescanCharset("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=iso-8859-1\">").?,
|
||||
);
|
||||
|
||||
// No charset found
|
||||
try testing.expectEqual(null, Mime.prescanCharset("<html><head><title>Test</title>"));
|
||||
try testing.expectEqual(null, Mime.prescanCharset(""));
|
||||
try testing.expectEqual(null, Mime.prescanCharset("no html here"));
|
||||
|
||||
// Self-closing meta without charset must not loop forever
|
||||
try testing.expectEqual(null, Mime.prescanCharset("<meta foo=\"bar\"/>"));
|
||||
|
||||
// Charset after 1024 bytes should not be found
|
||||
var long_html: [1100]u8 = undefined;
|
||||
@memset(&long_html, ' ');
|
||||
const suffix = "<meta charset=\"windows-1252\">";
|
||||
@memcpy(long_html[1050 .. 1050 + suffix.len], suffix);
|
||||
try testing.expectEqual(null, Mime.prescanCharset(&long_html));
|
||||
}
|
||||
3543
src/browser/Page.zig
3543
src/browser/Page.zig
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,674 +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 lp = @import("lightpanda");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
const log = @import("../log.zig");
|
||||
const App = @import("../App.zig");
|
||||
|
||||
const js = @import("js/js.zig");
|
||||
const storage = @import("webapi/storage/storage.zig");
|
||||
const Navigation = @import("webapi/navigation/Navigation.zig");
|
||||
const History = @import("webapi/History.zig");
|
||||
|
||||
const Page = @import("Page.zig");
|
||||
const Browser = @import("Browser.zig");
|
||||
const Factory = @import("Factory.zig");
|
||||
const Notification = @import("../Notification.zig");
|
||||
const QueuedNavigation = Page.QueuedNavigation;
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaPool = App.ArenaPool;
|
||||
const IS_DEBUG = builtin.mode == .Debug;
|
||||
|
||||
// You can create successively multiple pages for a session, but you must
|
||||
// deinit a page before running another one. It manages two distinct lifetimes.
|
||||
//
|
||||
// The first is the lifetime of the Session itself, where pages are created and
|
||||
// removed, but share the same cookie jar and navigation history (etc...)
|
||||
//
|
||||
// The second is as a container the data needed by the full page hierarchy, i.e. \
|
||||
// the root page and all of its frames (and all of their frames.)
|
||||
const Session = @This();
|
||||
|
||||
// These are the fields that remain intact for the duration of the Session
|
||||
browser: *Browser,
|
||||
arena: Allocator,
|
||||
history: History,
|
||||
navigation: Navigation,
|
||||
storage_shed: storage.Shed,
|
||||
notification: *Notification,
|
||||
cookie_jar: storage.Cookie.Jar,
|
||||
|
||||
// These are the fields that get reset whenever the Session's page (the root) is reset.
|
||||
factory: Factory,
|
||||
|
||||
page_arena: Allocator,
|
||||
|
||||
// Origin map for same-origin context sharing. Scoped to the root page lifetime.
|
||||
origins: std.StringHashMapUnmanaged(*js.Origin) = .empty,
|
||||
|
||||
// Shared resources for all pages in this session.
|
||||
// These live for the duration of the page tree (root + frames).
|
||||
arena_pool: *ArenaPool,
|
||||
|
||||
// In Debug, we use this to see if anything fails to release an arena back to
|
||||
// the pool.
|
||||
_arena_pool_leak_track: if (IS_DEBUG) std.AutoHashMapUnmanaged(usize, struct {
|
||||
owner: []const u8,
|
||||
count: usize,
|
||||
}) else void = if (IS_DEBUG) .empty else {},
|
||||
|
||||
page: ?Page,
|
||||
|
||||
queued_navigation: std.ArrayList(*Page),
|
||||
// Temporary buffer for about:blank navigations during processing.
|
||||
// We process async navigations first (safe from re-entrance), then sync
|
||||
// about:blank navigations (which may add to queued_navigation).
|
||||
queued_queued_navigation: std.ArrayList(*Page),
|
||||
|
||||
page_id_gen: u32,
|
||||
frame_id_gen: u32,
|
||||
|
||||
pub fn init(self: *Session, browser: *Browser, notification: *Notification) !void {
|
||||
const allocator = browser.app.allocator;
|
||||
const arena_pool = browser.arena_pool;
|
||||
|
||||
const arena = try arena_pool.acquire();
|
||||
errdefer arena_pool.release(arena);
|
||||
|
||||
const page_arena = try arena_pool.acquire();
|
||||
errdefer arena_pool.release(page_arena);
|
||||
|
||||
self.* = .{
|
||||
.page = null,
|
||||
.arena = arena,
|
||||
.arena_pool = arena_pool,
|
||||
.page_arena = page_arena,
|
||||
.factory = Factory.init(page_arena),
|
||||
.history = .{},
|
||||
.page_id_gen = 0,
|
||||
.frame_id_gen = 0,
|
||||
// The prototype (EventTarget) for Navigation is created when a Page is created.
|
||||
.navigation = .{ ._proto = undefined },
|
||||
.storage_shed = .{},
|
||||
.browser = browser,
|
||||
.queued_navigation = .{},
|
||||
.queued_queued_navigation = .{},
|
||||
.notification = notification,
|
||||
.cookie_jar = storage.Cookie.Jar.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Session) void {
|
||||
if (self.page != null) {
|
||||
self.removePage();
|
||||
}
|
||||
self.cookie_jar.deinit();
|
||||
|
||||
self.storage_shed.deinit(self.browser.app.allocator);
|
||||
self.arena_pool.release(self.page_arena);
|
||||
self.arena_pool.release(self.arena);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
lp.assert(self.page == null, "Session.createPage - page not null", .{});
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, self.nextFrameId(), self, null);
|
||||
|
||||
// Creates a new NavigationEventTarget for this page.
|
||||
try self.navigation.onNewPage(page);
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
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.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.notification.dispatch(.page_remove, .{});
|
||||
lp.assert(self.page != null, "Session.removePage - page is null", .{});
|
||||
|
||||
self.page.?.deinit(false);
|
||||
self.page = null;
|
||||
|
||||
self.navigation.onRemovePage();
|
||||
self.resetPageResources();
|
||||
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.browser, "remove page", .{});
|
||||
}
|
||||
}
|
||||
|
||||
pub const GetArenaOpts = struct {
|
||||
debug: []const u8,
|
||||
};
|
||||
|
||||
pub fn getArena(self: *Session, opts: GetArenaOpts) !Allocator {
|
||||
const allocator = try self.arena_pool.acquire();
|
||||
if (comptime IS_DEBUG) {
|
||||
// Use session's arena (not page_arena) since page_arena gets reset between pages
|
||||
const gop = try self._arena_pool_leak_track.getOrPut(self.arena, @intFromPtr(allocator.ptr));
|
||||
if (gop.found_existing and gop.value_ptr.count != 0) {
|
||||
log.err(.bug, "ArenaPool Double Use", .{ .owner = gop.value_ptr.*.owner });
|
||||
@panic("ArenaPool Double Use");
|
||||
}
|
||||
gop.value_ptr.* = .{ .owner = opts.debug, .count = 1 };
|
||||
}
|
||||
return allocator;
|
||||
}
|
||||
|
||||
pub fn releaseArena(self: *Session, allocator: Allocator) void {
|
||||
if (comptime IS_DEBUG) {
|
||||
const found = self._arena_pool_leak_track.getPtr(@intFromPtr(allocator.ptr)).?;
|
||||
if (found.count != 1) {
|
||||
log.err(.bug, "ArenaPool Double Free", .{ .owner = found.owner, .count = found.count });
|
||||
if (comptime builtin.is_test) {
|
||||
@panic("ArenaPool Double Free");
|
||||
}
|
||||
return;
|
||||
}
|
||||
found.count = 0;
|
||||
}
|
||||
return self.arena_pool.release(allocator);
|
||||
}
|
||||
|
||||
pub fn getOrCreateOrigin(self: *Session, key_: ?[]const u8) !*js.Origin {
|
||||
const key = key_ orelse {
|
||||
var opaque_origin: [36]u8 = undefined;
|
||||
@import("../id.zig").uuidv4(&opaque_origin);
|
||||
// Origin.init will dupe opaque_origin. It's fine that this doesn't
|
||||
// get added to self.origins. In fact, it further isolates it. When the
|
||||
// context is freed, it'll call session.releaseOrigin which will free it.
|
||||
return js.Origin.init(self.browser.app, self.browser.env.isolate, &opaque_origin);
|
||||
};
|
||||
|
||||
const gop = try self.origins.getOrPut(self.arena, key);
|
||||
if (gop.found_existing) {
|
||||
const origin = gop.value_ptr.*;
|
||||
origin.rc += 1;
|
||||
return origin;
|
||||
}
|
||||
|
||||
errdefer _ = self.origins.remove(key);
|
||||
|
||||
const origin = try js.Origin.init(self.browser.app, self.browser.env.isolate, key);
|
||||
gop.key_ptr.* = origin.key;
|
||||
gop.value_ptr.* = origin;
|
||||
return origin;
|
||||
}
|
||||
|
||||
pub fn releaseOrigin(self: *Session, origin: *js.Origin) void {
|
||||
const rc = origin.rc;
|
||||
if (rc == 1) {
|
||||
_ = self.origins.remove(origin.key);
|
||||
origin.deinit(self.browser.app);
|
||||
} else {
|
||||
origin.rc = rc - 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset page_arena and factory for a clean slate.
|
||||
/// Called when root page is removed.
|
||||
fn resetPageResources(self: *Session) void {
|
||||
// Check for arena leaks before releasing
|
||||
if (comptime IS_DEBUG) {
|
||||
var it = self._arena_pool_leak_track.valueIterator();
|
||||
while (it.next()) |value_ptr| {
|
||||
if (value_ptr.count > 0) {
|
||||
log.err(.bug, "ArenaPool Leak", .{ .owner = value_ptr.owner });
|
||||
}
|
||||
}
|
||||
self._arena_pool_leak_track.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// All origins should have been released when contexts were destroyed
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(self.origins.count() == 0);
|
||||
}
|
||||
// Defensive cleanup in case origins leaked
|
||||
{
|
||||
const app = self.browser.app;
|
||||
var it = self.origins.valueIterator();
|
||||
while (it.next()) |value| {
|
||||
value.*.deinit(app);
|
||||
}
|
||||
self.origins.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
// Release old page_arena and acquire fresh one
|
||||
self.frame_id_gen = 0;
|
||||
self.arena_pool.reset(self.page_arena, 64 * 1024);
|
||||
self.factory = Factory.init(self.page_arena);
|
||||
}
|
||||
|
||||
pub fn replacePage(self: *Session) !*Page {
|
||||
if (comptime IS_DEBUG) {
|
||||
log.debug(.browser, "replace page", .{});
|
||||
}
|
||||
|
||||
lp.assert(self.page != null, "Session.replacePage null page", .{});
|
||||
lp.assert(self.page.?.parent == null, "Session.replacePage with parent", .{});
|
||||
|
||||
var current = self.page.?;
|
||||
const frame_id = current._frame_id;
|
||||
current.deinit(true);
|
||||
|
||||
self.resetPageResources();
|
||||
self.browser.env.memoryPressureNotification(.moderate);
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const page = &self.page.?;
|
||||
try Page.init(page, frame_id, self, null);
|
||||
return page;
|
||||
}
|
||||
|
||||
pub fn currentPage(self: *Session) ?*Page {
|
||||
return &(self.page orelse return null);
|
||||
}
|
||||
|
||||
pub const WaitResult = enum {
|
||||
done,
|
||||
no_page,
|
||||
cdp_socket,
|
||||
};
|
||||
|
||||
pub fn findPageByFrameId(self: *Session, frame_id: u32) ?*Page {
|
||||
const page = self.currentPage() orelse return null;
|
||||
return findPageBy(page, "_frame_id", frame_id);
|
||||
}
|
||||
|
||||
pub fn findPageById(self: *Session, id: u32) ?*Page {
|
||||
const page = self.currentPage() orelse return null;
|
||||
return findPageBy(page, "id", id);
|
||||
}
|
||||
|
||||
fn findPageBy(page: *Page, comptime field: []const u8, id: u32) ?*Page {
|
||||
if (@field(page, field) == id) return page;
|
||||
for (page.frames.items) |f| {
|
||||
if (findPageBy(f, field, id)) |found| {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn wait(self: *Session, wait_ms: u32) WaitResult {
|
||||
var page = &(self.page orelse return .no_page);
|
||||
while (true) {
|
||||
const wait_result = self._wait(page, wait_ms) catch |err| {
|
||||
switch (err) {
|
||||
error.JsError => {}, // already logged (with hopefully more context)
|
||||
else => log.err(.browser, "session wait", .{
|
||||
.err = err,
|
||||
.url = page.url,
|
||||
}),
|
||||
}
|
||||
return .done;
|
||||
};
|
||||
|
||||
switch (wait_result) {
|
||||
.done => {
|
||||
if (self.queued_navigation.items.len == 0) {
|
||||
return .done;
|
||||
}
|
||||
self.processQueuedNavigation() catch return .done;
|
||||
page = &self.page.?; // might have changed
|
||||
},
|
||||
else => |result| return result,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn _wait(self: *Session, page: *Page, wait_ms: u32) !WaitResult {
|
||||
var timer = try std.time.Timer.start();
|
||||
var ms_remaining = wait_ms;
|
||||
|
||||
const browser = self.browser;
|
||||
var http_client = browser.http_client;
|
||||
|
||||
// I'd like the page to know NOTHING about cdp_socket / CDP, but the
|
||||
// fact is that the behavior of wait changes depending on whether or
|
||||
// not we're using CDP.
|
||||
// If we aren't using CDP, as soon as we think there's nothing left
|
||||
// to do, we can exit - we'de done.
|
||||
// But if we are using CDP, we should wait for the whole `wait_ms`
|
||||
// because the http_click.tick() also monitors the CDP socket. And while
|
||||
// we could let CDP poll http (like it does for HTTP requests), the fact
|
||||
// is that we know more about the timing of stuff (e.g. how long to
|
||||
// poll/sleep) in the page.
|
||||
const exit_when_done = http_client.cdp_client == null;
|
||||
|
||||
while (true) {
|
||||
switch (page._parse_state) {
|
||||
.pre, .raw, .text, .image => {
|
||||
// The main page hasn't started/finished navigating.
|
||||
// There's no JS to run, and no reason to run the scheduler.
|
||||
if (http_client.active == 0 and exit_when_done) {
|
||||
// haven't started navigating, I guess.
|
||||
return .done;
|
||||
}
|
||||
// Either we have active http connections, or we're in CDP
|
||||
// mode with an extra socket. Either way, we're waiting
|
||||
// for http traffic
|
||||
if (try http_client.tick(@intCast(ms_remaining)) == .cdp_socket) {
|
||||
// exit_when_done is explicitly set when there isn't
|
||||
// an extra socket, so it should not be possibl to
|
||||
// get an cdp_socket message when exit_when_done
|
||||
// is true.
|
||||
if (IS_DEBUG) {
|
||||
std.debug.assert(exit_when_done == false);
|
||||
}
|
||||
|
||||
// data on a socket we aren't handling, return to caller
|
||||
return .cdp_socket;
|
||||
}
|
||||
},
|
||||
.html, .complete => {
|
||||
if (self.queued_navigation.items.len != 0) {
|
||||
return .done;
|
||||
}
|
||||
|
||||
// The HTML page was parsed. We now either have JS scripts to
|
||||
// download, or scheduled tasks to execute, or both.
|
||||
|
||||
// scheduler.run could trigger new http transfers, so do not
|
||||
// store http_client.active BEFORE this call and then use
|
||||
// it AFTER.
|
||||
try browser.runMacrotasks();
|
||||
|
||||
// Each call to this runs scheduled load events.
|
||||
try page.dispatchLoad();
|
||||
|
||||
const http_active = http_client.active;
|
||||
const total_network_activity = http_active + http_client.intercepted;
|
||||
if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
|
||||
page.notifyNetworkAlmostIdle();
|
||||
}
|
||||
if (page._notified_network_idle.check(total_network_activity == 0)) {
|
||||
page.notifyNetworkIdle();
|
||||
}
|
||||
|
||||
if (http_active == 0 and exit_when_done) {
|
||||
// we don't need to consider http_client.intercepted here
|
||||
// because exit_when_done is true, and that can only be
|
||||
// the case when interception isn't possible.
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(http_client.intercepted == 0);
|
||||
}
|
||||
|
||||
var ms = blk: {
|
||||
// if (wait_ms - ms_remaining < 100) {
|
||||
// if (comptime builtin.is_test) {
|
||||
// return .done;
|
||||
// }
|
||||
// // Look, we want to exit ASAP, but we don't want
|
||||
// // to exit so fast that we've run none of the
|
||||
// // background jobs.
|
||||
// break :blk 50;
|
||||
// }
|
||||
|
||||
if (browser.hasBackgroundTasks()) {
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
// background tasks. We'll wait for them.
|
||||
browser.waitForBackgroundTasks();
|
||||
break :blk 20;
|
||||
}
|
||||
|
||||
break :blk browser.msToNextMacrotask() orelse return .done;
|
||||
};
|
||||
|
||||
if (ms > ms_remaining) {
|
||||
// Same as above, except we have a scheduled task,
|
||||
// it just happens to be too far into the future
|
||||
// compared to how long we were told to wait.
|
||||
if (!browser.hasBackgroundTasks()) {
|
||||
return .done;
|
||||
}
|
||||
// _we_ have nothing to run, but v8 is working on
|
||||
// background tasks. We'll wait for them.
|
||||
browser.waitForBackgroundTasks();
|
||||
ms = 20;
|
||||
}
|
||||
|
||||
// We have a task to run in the not-so-distant future.
|
||||
// You might think we can just sleep until that task is
|
||||
// ready, but we should continue to run lowPriority tasks
|
||||
// in the meantime, and that could unblock things. So
|
||||
// we'll just sleep for a bit, and then restart our wait
|
||||
// loop to see if anything new can be processed.
|
||||
std.Thread.sleep(std.time.ns_per_ms * @as(u64, @intCast(@min(ms, 20))));
|
||||
} else {
|
||||
// We're here because we either have active HTTP
|
||||
// connections, or exit_when_done == false (aka, there's
|
||||
// an cdp_socket registered with the http client).
|
||||
// We should continue to run tasks, so we minimize how long
|
||||
// we'll poll for network I/O.
|
||||
var ms_to_wait = @min(200, browser.msToNextMacrotask() orelse 200);
|
||||
if (ms_to_wait > 10 and browser.hasBackgroundTasks()) {
|
||||
// if we have background tasks, we don't want to wait too
|
||||
// long for a message from the client. We want to go back
|
||||
// to the top of the loop and run macrotasks.
|
||||
ms_to_wait = 10;
|
||||
}
|
||||
if (try http_client.tick(@min(ms_remaining, ms_to_wait)) == .cdp_socket) {
|
||||
// data on a socket we aren't handling, return to caller
|
||||
return .cdp_socket;
|
||||
}
|
||||
}
|
||||
},
|
||||
.err => |err| {
|
||||
page._parse_state = .{ .raw_done = @errorName(err) };
|
||||
return err;
|
||||
},
|
||||
.raw_done => {
|
||||
if (exit_when_done) {
|
||||
return .done;
|
||||
}
|
||||
// we _could_ http_client.tick(ms_to_wait), but this has
|
||||
// the same result, and I feel is more correct.
|
||||
return .no_page;
|
||||
},
|
||||
}
|
||||
|
||||
const ms_elapsed = timer.lap() / 1_000_000;
|
||||
if (ms_elapsed >= ms_remaining) {
|
||||
return .done;
|
||||
}
|
||||
ms_remaining -= @intCast(ms_elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scheduleNavigation(self: *Session, page: *Page) !void {
|
||||
const list = &self.queued_navigation;
|
||||
|
||||
// Check if page is already queued
|
||||
for (list.items) |existing| {
|
||||
if (existing == page) {
|
||||
// Already queued
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return list.append(self.arena, page);
|
||||
}
|
||||
|
||||
fn processQueuedNavigation(self: *Session) !void {
|
||||
const navigations = &self.queued_navigation;
|
||||
|
||||
if (self.page.?._queued_navigation != null) {
|
||||
// This is both an optimization and a simplification of sorts. If the
|
||||
// root page is navigating, then we don't need to process any other
|
||||
// navigation. Also, the navigation for the root page and for a frame
|
||||
// is different enough that have two distinct code blocks is, imo,
|
||||
// better. Yes, there will be duplication.
|
||||
navigations.clearRetainingCapacity();
|
||||
return self.processRootQueuedNavigation();
|
||||
}
|
||||
|
||||
const about_blank_queue = &self.queued_queued_navigation;
|
||||
defer about_blank_queue.clearRetainingCapacity();
|
||||
|
||||
// First pass: process async navigations (non-about:blank)
|
||||
// These cannot cause re-entrant navigation scheduling
|
||||
for (navigations.items) |page| {
|
||||
const qn = page._queued_navigation.?;
|
||||
|
||||
if (qn.is_about_blank) {
|
||||
// Defer about:blank to second pass
|
||||
try about_blank_queue.append(self.arena, page);
|
||||
continue;
|
||||
}
|
||||
|
||||
self.processFrameNavigation(page, qn) catch |err| {
|
||||
log.warn(.page, "frame navigation", .{ .url = qn.url, .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
// Clear the queue after first pass
|
||||
navigations.clearRetainingCapacity();
|
||||
|
||||
// Second pass: process synchronous navigations (about:blank)
|
||||
// These may trigger new navigations which go into queued_navigation
|
||||
for (about_blank_queue.items) |page| {
|
||||
const qn = page._queued_navigation.?;
|
||||
try self.processFrameNavigation(page, qn);
|
||||
}
|
||||
|
||||
// Safety: Remove any about:blank navigations that were queued during the
|
||||
// second pass to prevent infinite loops
|
||||
var i: usize = 0;
|
||||
while (i < navigations.items.len) {
|
||||
const page = navigations.items[i];
|
||||
if (page._queued_navigation) |qn| {
|
||||
if (qn.is_about_blank) {
|
||||
log.warn(.page, "recursive about blank", .{});
|
||||
_ = navigations.swapRemove(i);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn processFrameNavigation(self: *Session, page: *Page, qn: *QueuedNavigation) !void {
|
||||
lp.assert(page.parent != null, "root queued navigation", .{});
|
||||
|
||||
const iframe = page.iframe.?;
|
||||
const parent = page.parent.?;
|
||||
|
||||
page._queued_navigation = null;
|
||||
defer self.releaseArena(qn.arena);
|
||||
|
||||
errdefer iframe._window = null;
|
||||
|
||||
const parent_notified = page._parent_notified;
|
||||
if (parent_notified) {
|
||||
// we already notified the parent that we had loaded
|
||||
parent._pending_loads += 1;
|
||||
}
|
||||
|
||||
const frame_id = page._frame_id;
|
||||
page.deinit(true);
|
||||
page.* = undefined;
|
||||
|
||||
try Page.init(page, frame_id, self, parent);
|
||||
errdefer {
|
||||
for (parent.frames.items, 0..) |frame, i| {
|
||||
if (frame == page) {
|
||||
parent.frames_sorted = false;
|
||||
_ = parent.frames.swapRemove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (parent_notified) {
|
||||
parent._pending_loads -= 1;
|
||||
}
|
||||
page.deinit(true);
|
||||
}
|
||||
|
||||
page.iframe = iframe;
|
||||
iframe._window = page.window;
|
||||
|
||||
page.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued frame navigation error", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn processRootQueuedNavigation(self: *Session) !void {
|
||||
const current_page = &self.page.?;
|
||||
const frame_id = current_page._frame_id;
|
||||
|
||||
// create a copy before the page is cleared
|
||||
const qn = current_page._queued_navigation.?;
|
||||
current_page._queued_navigation = null;
|
||||
|
||||
defer self.arena_pool.release(qn.arena);
|
||||
|
||||
// HACK
|
||||
// Mark as released in tracking BEFORE removePage clears the map.
|
||||
// We can't call releaseArena() because that would also return the arena
|
||||
// to the pool, making the memory invalid before we use qn.url/qn.opts.
|
||||
if (comptime IS_DEBUG) {
|
||||
if (self._arena_pool_leak_track.getPtr(@intFromPtr(qn.arena.ptr))) |found| {
|
||||
found.count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
self.removePage();
|
||||
|
||||
self.page = @as(Page, undefined);
|
||||
const new_page = &self.page.?;
|
||||
try Page.init(new_page, frame_id, self, null);
|
||||
|
||||
// Creates a new NavigationEventTarget for this page.
|
||||
try self.navigation.onNewPage(new_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.notification.dispatch(.page_created, new_page);
|
||||
|
||||
new_page.navigate(qn.url, qn.opts) catch |err| {
|
||||
log.err(.browser, "queued navigation error", .{ .err = err });
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn nextFrameId(self: *Session) u32 {
|
||||
const id = self.frame_id_gen +% 1;
|
||||
self.frame_id_gen = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
pub fn nextPageId(self: *Session) u32 {
|
||||
const id = self.page_id_gen +% 1;
|
||||
self.page_id_gen = id;
|
||||
return id;
|
||||
}
|
||||
65
src/browser/State.zig
Normal file
65
src/browser/State.zig
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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 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,
|
||||
|
||||
// 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,
|
||||
|
||||
const ReadyState = enum {
|
||||
loading,
|
||||
interactive,
|
||||
complete,
|
||||
};
|
||||
1416
src/browser/URL.zig
1416
src/browser/URL.zig
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("../lightpanda.zig");
|
||||
const DOMNode = @import("webapi/Node.zig");
|
||||
const Element = @import("webapi/Element.zig");
|
||||
const Event = @import("webapi/Event.zig");
|
||||
const MouseEvent = @import("webapi/event/MouseEvent.zig");
|
||||
const Page = @import("Page.zig");
|
||||
|
||||
pub fn click(node: *DOMNode, page: *Page) !void {
|
||||
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
const mouse_event: *MouseEvent = try .initTrusted(comptime .wrap("click"), .{
|
||||
.bubbles = true,
|
||||
.cancelable = true,
|
||||
.composed = true,
|
||||
.clientX = 0,
|
||||
.clientY = 0,
|
||||
}, page);
|
||||
|
||||
page._event_manager.dispatch(el.asEventTarget(), mouse_event.asEvent()) catch |err| {
|
||||
lp.log.err(.app, "click failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn fill(node: *DOMNode, text: []const u8, page: *Page) !void {
|
||||
const el = node.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
if (el.is(Element.Html.Input)) |input| {
|
||||
input.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill input failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else if (el.is(Element.Html.TextArea)) |textarea| {
|
||||
textarea.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill textarea failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else if (el.is(Element.Html.Select)) |select| {
|
||||
select.setValue(text, page) catch |err| {
|
||||
lp.log.err(.app, "fill select failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
} else {
|
||||
return error.InvalidNodeType;
|
||||
}
|
||||
|
||||
const input_evt: *Event = try .initTrusted(comptime .wrap("input"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), input_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch input event failed", .{ .err = err });
|
||||
};
|
||||
|
||||
const change_evt: *Event = try .initTrusted(comptime .wrap("change"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), change_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch change event failed", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
pub fn scroll(node: ?*DOMNode, x: ?i32, y: ?i32, page: *Page) !void {
|
||||
if (node) |n| {
|
||||
const el = n.is(Element) orelse return error.InvalidNodeType;
|
||||
|
||||
if (x) |val| {
|
||||
el.setScrollLeft(val, page) catch |err| {
|
||||
lp.log.err(.app, "setScrollLeft failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
if (y) |val| {
|
||||
el.setScrollTop(val, page) catch |err| {
|
||||
lp.log.err(.app, "setScrollTop failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
|
||||
const scroll_evt: *Event = try .initTrusted(comptime .wrap("scroll"), .{ .bubbles = true }, page);
|
||||
page._event_manager.dispatch(el.asEventTarget(), scroll_evt) catch |err| {
|
||||
lp.log.err(.app, "dispatch scroll event failed", .{ .err = err });
|
||||
};
|
||||
} else {
|
||||
page.window.scrollTo(.{ .x = x orelse 0 }, y, page) catch |err| {
|
||||
lp.log.err(.app, "scroll failed", .{ .err = err });
|
||||
return error.ActionFailed;
|
||||
};
|
||||
}
|
||||
}
|
||||
111
src/browser/browser.zig
Normal file
111
src/browser/browser.zig
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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 ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
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 http = @import("../http/client.zig");
|
||||
|
||||
// Browser is an instance of the browser.
|
||||
// You can create multiple browser instances.
|
||||
// A browser contains only one session.
|
||||
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),
|
||||
|
||||
pub fn init(app: *App) !Browser {
|
||||
const allocator = app.allocator;
|
||||
|
||||
const env = try Env.init(allocator, .{});
|
||||
errdefer env.deinit();
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 {
|
||||
return self.env.runMicrotasks();
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
test "Browser" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
// this will crash if ICU isn't properly configured / ininitialized
|
||||
try runner.testCases(&.{
|
||||
.{ "new Intl.DateTimeFormat()", "[object Intl.DateTimeFormat]" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,298 +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 Io = std.Io;
|
||||
|
||||
pub fn isHexColor(value: []const u8) bool {
|
||||
if (value.len == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value[0] != '#') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hex_part = value[1..];
|
||||
switch (hex_part.len) {
|
||||
3, 4, 6, 8 => for (hex_part) |c| if (!std.ascii.isHex(c)) return false,
|
||||
else => return false,
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub const RGBA = packed struct(u32) {
|
||||
r: u8,
|
||||
g: u8,
|
||||
b: u8,
|
||||
/// Opaque by default.
|
||||
a: u8 = std.math.maxInt(u8),
|
||||
|
||||
pub const Named = struct {
|
||||
// Basic colors (CSS Level 1)
|
||||
pub const black: RGBA = .init(0, 0, 0, 1);
|
||||
pub const silver: RGBA = .init(192, 192, 192, 1);
|
||||
pub const gray: RGBA = .init(128, 128, 128, 1);
|
||||
pub const white: RGBA = .init(255, 255, 255, 1);
|
||||
pub const maroon: RGBA = .init(128, 0, 0, 1);
|
||||
pub const red: RGBA = .init(255, 0, 0, 1);
|
||||
pub const purple: RGBA = .init(128, 0, 128, 1);
|
||||
pub const fuchsia: RGBA = .init(255, 0, 255, 1);
|
||||
pub const green: RGBA = .init(0, 128, 0, 1);
|
||||
pub const lime: RGBA = .init(0, 255, 0, 1);
|
||||
pub const olive: RGBA = .init(128, 128, 0, 1);
|
||||
pub const yellow: RGBA = .init(255, 255, 0, 1);
|
||||
pub const navy: RGBA = .init(0, 0, 128, 1);
|
||||
pub const blue: RGBA = .init(0, 0, 255, 1);
|
||||
pub const teal: RGBA = .init(0, 128, 128, 1);
|
||||
pub const aqua: RGBA = .init(0, 255, 255, 1);
|
||||
|
||||
// Extended colors (CSS Level 2+)
|
||||
pub const aliceblue: RGBA = .init(240, 248, 255, 1);
|
||||
pub const antiquewhite: RGBA = .init(250, 235, 215, 1);
|
||||
pub const aquamarine: RGBA = .init(127, 255, 212, 1);
|
||||
pub const azure: RGBA = .init(240, 255, 255, 1);
|
||||
pub const beige: RGBA = .init(245, 245, 220, 1);
|
||||
pub const bisque: RGBA = .init(255, 228, 196, 1);
|
||||
pub const blanchedalmond: RGBA = .init(255, 235, 205, 1);
|
||||
pub const blueviolet: RGBA = .init(138, 43, 226, 1);
|
||||
pub const brown: RGBA = .init(165, 42, 42, 1);
|
||||
pub const burlywood: RGBA = .init(222, 184, 135, 1);
|
||||
pub const cadetblue: RGBA = .init(95, 158, 160, 1);
|
||||
pub const chartreuse: RGBA = .init(127, 255, 0, 1);
|
||||
pub const chocolate: RGBA = .init(210, 105, 30, 1);
|
||||
pub const coral: RGBA = .init(255, 127, 80, 1);
|
||||
pub const cornflowerblue: RGBA = .init(100, 149, 237, 1);
|
||||
pub const cornsilk: RGBA = .init(255, 248, 220, 1);
|
||||
pub const crimson: RGBA = .init(220, 20, 60, 1);
|
||||
pub const cyan: RGBA = .init(0, 255, 255, 1); // Synonym of aqua
|
||||
pub const darkblue: RGBA = .init(0, 0, 139, 1);
|
||||
pub const darkcyan: RGBA = .init(0, 139, 139, 1);
|
||||
pub const darkgoldenrod: RGBA = .init(184, 134, 11, 1);
|
||||
pub const darkgray: RGBA = .init(169, 169, 169, 1);
|
||||
pub const darkgreen: RGBA = .init(0, 100, 0, 1);
|
||||
pub const darkgrey: RGBA = .init(169, 169, 169, 1); // Synonym of darkgray
|
||||
pub const darkkhaki: RGBA = .init(189, 183, 107, 1);
|
||||
pub const darkmagenta: RGBA = .init(139, 0, 139, 1);
|
||||
pub const darkolivegreen: RGBA = .init(85, 107, 47, 1);
|
||||
pub const darkorange: RGBA = .init(255, 140, 0, 1);
|
||||
pub const darkorchid: RGBA = .init(153, 50, 204, 1);
|
||||
pub const darkred: RGBA = .init(139, 0, 0, 1);
|
||||
pub const darksalmon: RGBA = .init(233, 150, 122, 1);
|
||||
pub const darkseagreen: RGBA = .init(143, 188, 143, 1);
|
||||
pub const darkslateblue: RGBA = .init(72, 61, 139, 1);
|
||||
pub const darkslategray: RGBA = .init(47, 79, 79, 1);
|
||||
pub const darkslategrey: RGBA = .init(47, 79, 79, 1); // Synonym of darkslategray
|
||||
pub const darkturquoise: RGBA = .init(0, 206, 209, 1);
|
||||
pub const darkviolet: RGBA = .init(148, 0, 211, 1);
|
||||
pub const deeppink: RGBA = .init(255, 20, 147, 1);
|
||||
pub const deepskyblue: RGBA = .init(0, 191, 255, 1);
|
||||
pub const dimgray: RGBA = .init(105, 105, 105, 1);
|
||||
pub const dimgrey: RGBA = .init(105, 105, 105, 1); // Synonym of dimgray
|
||||
pub const dodgerblue: RGBA = .init(30, 144, 255, 1);
|
||||
pub const firebrick: RGBA = .init(178, 34, 34, 1);
|
||||
pub const floralwhite: RGBA = .init(255, 250, 240, 1);
|
||||
pub const forestgreen: RGBA = .init(34, 139, 34, 1);
|
||||
pub const gainsboro: RGBA = .init(220, 220, 220, 1);
|
||||
pub const ghostwhite: RGBA = .init(248, 248, 255, 1);
|
||||
pub const gold: RGBA = .init(255, 215, 0, 1);
|
||||
pub const goldenrod: RGBA = .init(218, 165, 32, 1);
|
||||
pub const greenyellow: RGBA = .init(173, 255, 47, 1);
|
||||
pub const grey: RGBA = .init(128, 128, 128, 1); // Synonym of gray
|
||||
pub const honeydew: RGBA = .init(240, 255, 240, 1);
|
||||
pub const hotpink: RGBA = .init(255, 105, 180, 1);
|
||||
pub const indianred: RGBA = .init(205, 92, 92, 1);
|
||||
pub const indigo: RGBA = .init(75, 0, 130, 1);
|
||||
pub const ivory: RGBA = .init(255, 255, 240, 1);
|
||||
pub const khaki: RGBA = .init(240, 230, 140, 1);
|
||||
pub const lavender: RGBA = .init(230, 230, 250, 1);
|
||||
pub const lavenderblush: RGBA = .init(255, 240, 245, 1);
|
||||
pub const lawngreen: RGBA = .init(124, 252, 0, 1);
|
||||
pub const lemonchiffon: RGBA = .init(255, 250, 205, 1);
|
||||
pub const lightblue: RGBA = .init(173, 216, 230, 1);
|
||||
pub const lightcoral: RGBA = .init(240, 128, 128, 1);
|
||||
pub const lightcyan: RGBA = .init(224, 255, 255, 1);
|
||||
pub const lightgoldenrodyellow: RGBA = .init(250, 250, 210, 1);
|
||||
pub const lightgray: RGBA = .init(211, 211, 211, 1);
|
||||
pub const lightgreen: RGBA = .init(144, 238, 144, 1);
|
||||
pub const lightgrey: RGBA = .init(211, 211, 211, 1); // Synonym of lightgray
|
||||
pub const lightpink: RGBA = .init(255, 182, 193, 1);
|
||||
pub const lightsalmon: RGBA = .init(255, 160, 122, 1);
|
||||
pub const lightseagreen: RGBA = .init(32, 178, 170, 1);
|
||||
pub const lightskyblue: RGBA = .init(135, 206, 250, 1);
|
||||
pub const lightslategray: RGBA = .init(119, 136, 153, 1);
|
||||
pub const lightslategrey: RGBA = .init(119, 136, 153, 1); // Synonym of lightslategray
|
||||
pub const lightsteelblue: RGBA = .init(176, 196, 222, 1);
|
||||
pub const lightyellow: RGBA = .init(255, 255, 224, 1);
|
||||
pub const limegreen: RGBA = .init(50, 205, 50, 1);
|
||||
pub const linen: RGBA = .init(250, 240, 230, 1);
|
||||
pub const magenta: RGBA = .init(255, 0, 255, 1); // Synonym of fuchsia
|
||||
pub const mediumaquamarine: RGBA = .init(102, 205, 170, 1);
|
||||
pub const mediumblue: RGBA = .init(0, 0, 205, 1);
|
||||
pub const mediumorchid: RGBA = .init(186, 85, 211, 1);
|
||||
pub const mediumpurple: RGBA = .init(147, 112, 219, 1);
|
||||
pub const mediumseagreen: RGBA = .init(60, 179, 113, 1);
|
||||
pub const mediumslateblue: RGBA = .init(123, 104, 238, 1);
|
||||
pub const mediumspringgreen: RGBA = .init(0, 250, 154, 1);
|
||||
pub const mediumturquoise: RGBA = .init(72, 209, 204, 1);
|
||||
pub const mediumvioletred: RGBA = .init(199, 21, 133, 1);
|
||||
pub const midnightblue: RGBA = .init(25, 25, 112, 1);
|
||||
pub const mintcream: RGBA = .init(245, 255, 250, 1);
|
||||
pub const mistyrose: RGBA = .init(255, 228, 225, 1);
|
||||
pub const moccasin: RGBA = .init(255, 228, 181, 1);
|
||||
pub const navajowhite: RGBA = .init(255, 222, 173, 1);
|
||||
pub const oldlace: RGBA = .init(253, 245, 230, 1);
|
||||
pub const olivedrab: RGBA = .init(107, 142, 35, 1);
|
||||
pub const orange: RGBA = .init(255, 165, 0, 1);
|
||||
pub const orangered: RGBA = .init(255, 69, 0, 1);
|
||||
pub const orchid: RGBA = .init(218, 112, 214, 1);
|
||||
pub const palegoldenrod: RGBA = .init(238, 232, 170, 1);
|
||||
pub const palegreen: RGBA = .init(152, 251, 152, 1);
|
||||
pub const paleturquoise: RGBA = .init(175, 238, 238, 1);
|
||||
pub const palevioletred: RGBA = .init(219, 112, 147, 1);
|
||||
pub const papayawhip: RGBA = .init(255, 239, 213, 1);
|
||||
pub const peachpuff: RGBA = .init(255, 218, 185, 1);
|
||||
pub const peru: RGBA = .init(205, 133, 63, 1);
|
||||
pub const pink: RGBA = .init(255, 192, 203, 1);
|
||||
pub const plum: RGBA = .init(221, 160, 221, 1);
|
||||
pub const powderblue: RGBA = .init(176, 224, 230, 1);
|
||||
pub const rebeccapurple: RGBA = .init(102, 51, 153, 1);
|
||||
pub const rosybrown: RGBA = .init(188, 143, 143, 1);
|
||||
pub const royalblue: RGBA = .init(65, 105, 225, 1);
|
||||
pub const saddlebrown: RGBA = .init(139, 69, 19, 1);
|
||||
pub const salmon: RGBA = .init(250, 128, 114, 1);
|
||||
pub const sandybrown: RGBA = .init(244, 164, 96, 1);
|
||||
pub const seagreen: RGBA = .init(46, 139, 87, 1);
|
||||
pub const seashell: RGBA = .init(255, 245, 238, 1);
|
||||
pub const sienna: RGBA = .init(160, 82, 45, 1);
|
||||
pub const skyblue: RGBA = .init(135, 206, 235, 1);
|
||||
pub const slateblue: RGBA = .init(106, 90, 205, 1);
|
||||
pub const slategray: RGBA = .init(112, 128, 144, 1);
|
||||
pub const slategrey: RGBA = .init(112, 128, 144, 1); // Synonym of slategray
|
||||
pub const snow: RGBA = .init(255, 250, 250, 1);
|
||||
pub const springgreen: RGBA = .init(0, 255, 127, 1);
|
||||
pub const steelblue: RGBA = .init(70, 130, 180, 1);
|
||||
pub const tan: RGBA = .init(210, 180, 140, 1);
|
||||
pub const thistle: RGBA = .init(216, 191, 216, 1);
|
||||
pub const tomato: RGBA = .init(255, 99, 71, 1);
|
||||
pub const transparent: RGBA = .init(0, 0, 0, 0);
|
||||
pub const turquoise: RGBA = .init(64, 224, 208, 1);
|
||||
pub const violet: RGBA = .init(238, 130, 238, 1);
|
||||
pub const wheat: RGBA = .init(245, 222, 179, 1);
|
||||
pub const whitesmoke: RGBA = .init(245, 245, 245, 1);
|
||||
pub const yellowgreen: RGBA = .init(154, 205, 50, 1);
|
||||
};
|
||||
|
||||
pub fn init(r: u8, g: u8, b: u8, a: f32) RGBA {
|
||||
const clamped = std.math.clamp(a, 0, 1);
|
||||
return .{ .r = r, .g = g, .b = b, .a = @intFromFloat(clamped * 255) };
|
||||
}
|
||||
|
||||
/// Finds a color by its name.
|
||||
pub fn find(name: []const u8) ?RGBA {
|
||||
const match = std.meta.stringToEnum(std.meta.DeclEnum(Named), name) orelse return null;
|
||||
|
||||
return switch (match) {
|
||||
inline else => |comptime_enum| @field(Named, @tagName(comptime_enum)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Parses the given color.
|
||||
/// Currently we only parse hex colors and named colors; other variants
|
||||
/// require CSS evaluation.
|
||||
pub fn parse(input: []const u8) !RGBA {
|
||||
if (!isHexColor(input)) {
|
||||
// Try named colors.
|
||||
return find(input) orelse return error.Invalid;
|
||||
}
|
||||
|
||||
const slice = input[1..];
|
||||
switch (slice.len) {
|
||||
// This means the digit for a color is repeated.
|
||||
// Given HEX is #f0c, its interpreted the same as #FF00CC.
|
||||
3 => {
|
||||
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
|
||||
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
|
||||
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
|
||||
return .{ .r = r, .g = g, .b = b, .a = 255 };
|
||||
},
|
||||
4 => {
|
||||
const r = try std.fmt.parseInt(u8, &.{ slice[0], slice[0] }, 16);
|
||||
const g = try std.fmt.parseInt(u8, &.{ slice[1], slice[1] }, 16);
|
||||
const b = try std.fmt.parseInt(u8, &.{ slice[2], slice[2] }, 16);
|
||||
const a = try std.fmt.parseInt(u8, &.{ slice[3], slice[3] }, 16);
|
||||
return .{ .r = r, .g = g, .b = b, .a = a };
|
||||
},
|
||||
// Regular HEX format.
|
||||
6 => {
|
||||
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
|
||||
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
|
||||
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
|
||||
return .{ .r = r, .g = g, .b = b, .a = 255 };
|
||||
},
|
||||
8 => {
|
||||
const r = try std.fmt.parseInt(u8, slice[0..2], 16);
|
||||
const g = try std.fmt.parseInt(u8, slice[2..4], 16);
|
||||
const b = try std.fmt.parseInt(u8, slice[4..6], 16);
|
||||
const a = try std.fmt.parseInt(u8, slice[6..8], 16);
|
||||
return .{ .r = r, .g = g, .b = b, .a = a };
|
||||
},
|
||||
else => return error.Invalid,
|
||||
}
|
||||
}
|
||||
|
||||
/// By default, browsers prefer lowercase formatting.
|
||||
const format_upper = false;
|
||||
|
||||
/// Formats the `Color` according to web expectations.
|
||||
/// If color is opaque, HEX is preferred; RGBA otherwise.
|
||||
pub fn format(self: *const RGBA, writer: *Io.Writer) Io.Writer.Error!void {
|
||||
if (self.isOpaque()) {
|
||||
// Convert RGB to HEX.
|
||||
// https://gristle.tripod.com/hexconv.html
|
||||
// Hexadecimal characters up to 15.
|
||||
const char: []const u8 = "0123456789" ++ if (format_upper) "ABCDEF" else "abcdef";
|
||||
// This variant always prefers 6 digit format, +1 is for hash char.
|
||||
const buffer = [7]u8{
|
||||
'#',
|
||||
char[self.r >> 4],
|
||||
char[self.r & 15],
|
||||
char[self.g >> 4],
|
||||
char[self.g & 15],
|
||||
char[self.b >> 4],
|
||||
char[self.b & 15],
|
||||
};
|
||||
|
||||
return writer.writeAll(&buffer);
|
||||
}
|
||||
|
||||
// Prefer RGBA format for everything else.
|
||||
return writer.print("rgba({d}, {d}, {d}, {d:.2})", .{ self.r, self.g, self.b, self.normalizedAlpha() });
|
||||
}
|
||||
|
||||
/// Returns true if `Color` is opaque.
|
||||
pub inline fn isOpaque(self: *const RGBA) bool {
|
||||
return self.a == std.math.maxInt(u8);
|
||||
}
|
||||
|
||||
/// Returns the normalized alpha value.
|
||||
pub inline fn normalizedAlpha(self: *const RGBA) f32 {
|
||||
return @as(f32, @floatFromInt(self.a)) / 255;
|
||||
}
|
||||
};
|
||||
329
src/browser/console/console.zig
Normal file
329
src/browser/console/console.zig
Normal file
@@ -0,0 +1,329 @@
|
||||
// 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 static_lp(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.fatal(.console, "lightpanda", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_log(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.info(.console, "info", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_info(values: []JsObject, page: *Page) !void {
|
||||
return static_log(values, page);
|
||||
}
|
||||
|
||||
pub fn static_debug(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.debug(.console, "debug", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_warn(values: []JsObject, page: *Page) !void {
|
||||
if (values.len == 0) {
|
||||
return;
|
||||
}
|
||||
log.warn(.console, "warn", .{ .args = try serializeValues(values, page) });
|
||||
}
|
||||
|
||||
pub fn static_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 static_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 static_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;
|
||||
}
|
||||
};
|
||||
82
src/browser/crypto/crypto.zig
Normal file
82
src/browser/crypto/crypto.zig
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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 uuidv4 = @import("../../id.zig").uuidv4;
|
||||
|
||||
// https://w3c.github.io/webcrypto/#crypto-interface
|
||||
pub const Crypto = struct {
|
||||
pub fn _getRandomValues(_: *const Crypto, into: RandomValues) !void {
|
||||
const buf = into.asBuffer();
|
||||
if (buf.len > 65_536) {
|
||||
return error.QuotaExceededError;
|
||||
}
|
||||
std.crypto.random.bytes(buf);
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (self) {
|
||||
.int8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.uint8 => |b| return (@as([]u8, @ptrCast(b)))[0..b.len],
|
||||
.int16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.uint16 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 2],
|
||||
.int32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.uint32 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 4],
|
||||
.int64 => |b| return (@as([]u8, @ptrCast(b)))[0 .. b.len * 8],
|
||||
.uint64 => |b| return (@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" },
|
||||
.{ "a.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" },
|
||||
.{ "crypto.getRandomValues(r1)", "undefined" },
|
||||
.{ "new Set(r1).size", "5" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,295 +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 Tokenizer = @import("Tokenizer.zig");
|
||||
|
||||
pub const Declaration = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
important: bool,
|
||||
};
|
||||
|
||||
const TokenSpan = struct {
|
||||
token: Tokenizer.Token,
|
||||
start: usize,
|
||||
end: usize,
|
||||
};
|
||||
|
||||
const TokenStream = struct {
|
||||
tokenizer: Tokenizer,
|
||||
peeked: ?TokenSpan = null,
|
||||
|
||||
fn init(input: []const u8) TokenStream {
|
||||
return .{ .tokenizer = .{ .input = input } };
|
||||
}
|
||||
|
||||
fn nextRaw(self: *TokenStream) ?TokenSpan {
|
||||
const start = self.tokenizer.position;
|
||||
const token = self.tokenizer.next() orelse return null;
|
||||
const end = self.tokenizer.position;
|
||||
return .{ .token = token, .start = start, .end = end };
|
||||
}
|
||||
|
||||
fn next(self: *TokenStream) ?TokenSpan {
|
||||
if (self.peeked) |token| {
|
||||
self.peeked = null;
|
||||
return token;
|
||||
}
|
||||
return self.nextRaw();
|
||||
}
|
||||
|
||||
fn peek(self: *TokenStream) ?TokenSpan {
|
||||
if (self.peeked == null) {
|
||||
self.peeked = self.nextRaw();
|
||||
}
|
||||
return self.peeked;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn parseDeclarationsList(input: []const u8) DeclarationsIterator {
|
||||
return DeclarationsIterator.init(input);
|
||||
}
|
||||
|
||||
pub const DeclarationsIterator = struct {
|
||||
input: []const u8,
|
||||
stream: TokenStream,
|
||||
|
||||
pub fn init(input: []const u8) DeclarationsIterator {
|
||||
return .{
|
||||
.input = input,
|
||||
.stream = TokenStream.init(input),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn next(self: *DeclarationsIterator) ?Declaration {
|
||||
while (true) {
|
||||
self.skipTriviaAndSemicolons();
|
||||
const peeked = self.stream.peek() orelse return null;
|
||||
|
||||
switch (peeked.token) {
|
||||
.at_keyword => {
|
||||
_ = self.stream.next();
|
||||
self.skipAtRule();
|
||||
},
|
||||
.ident => |name| {
|
||||
_ = self.stream.next();
|
||||
if (self.consumeDeclaration(name)) |declaration| {
|
||||
return declaration;
|
||||
}
|
||||
},
|
||||
else => {
|
||||
_ = self.stream.next();
|
||||
self.skipInvalidDeclaration();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn consumeDeclaration(self: *DeclarationsIterator, name: []const u8) ?Declaration {
|
||||
self.skipTrivia();
|
||||
|
||||
const colon = self.stream.next() orelse return null;
|
||||
if (!isColon(colon.token)) {
|
||||
self.skipInvalidDeclaration();
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = self.consumeValue() orelse return null;
|
||||
return .{
|
||||
.name = name,
|
||||
.value = value.value,
|
||||
.important = value.important,
|
||||
};
|
||||
}
|
||||
|
||||
const ValueResult = struct {
|
||||
value: []const u8,
|
||||
important: bool,
|
||||
};
|
||||
|
||||
fn consumeValue(self: *DeclarationsIterator) ?ValueResult {
|
||||
self.skipTrivia();
|
||||
|
||||
var depth: usize = 0;
|
||||
var start: ?usize = null;
|
||||
var last_sig: ?TokenSpan = null;
|
||||
var prev_sig: ?TokenSpan = null;
|
||||
|
||||
while (true) {
|
||||
const peeked = self.stream.peek() orelse break;
|
||||
if (isSemicolon(peeked.token) and depth == 0) {
|
||||
_ = self.stream.next();
|
||||
break;
|
||||
}
|
||||
|
||||
const span = self.stream.next() orelse break;
|
||||
if (isWhitespaceOrComment(span.token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (start == null) start = span.start;
|
||||
prev_sig = last_sig;
|
||||
last_sig = span;
|
||||
updateDepth(span.token, &depth);
|
||||
}
|
||||
|
||||
const value_start = start orelse return null;
|
||||
const last = last_sig orelse return null;
|
||||
|
||||
var important = false;
|
||||
var end_pos = last.end;
|
||||
|
||||
if (isImportantPair(prev_sig, last)) {
|
||||
important = true;
|
||||
const bang = prev_sig orelse return null;
|
||||
if (value_start >= bang.start) return null;
|
||||
end_pos = bang.start;
|
||||
}
|
||||
|
||||
var value_slice = self.input[value_start..end_pos];
|
||||
value_slice = std.mem.trim(u8, value_slice, &std.ascii.whitespace);
|
||||
if (value_slice.len == 0) return null;
|
||||
|
||||
return .{ .value = value_slice, .important = important };
|
||||
}
|
||||
|
||||
fn skipTrivia(self: *DeclarationsIterator) void {
|
||||
while (self.stream.peek()) |peeked| {
|
||||
if (!isWhitespaceOrComment(peeked.token)) break;
|
||||
_ = self.stream.next();
|
||||
}
|
||||
}
|
||||
|
||||
fn skipTriviaAndSemicolons(self: *DeclarationsIterator) void {
|
||||
while (self.stream.peek()) |peeked| {
|
||||
if (isWhitespaceOrComment(peeked.token) or isSemicolon(peeked.token)) {
|
||||
_ = self.stream.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn skipAtRule(self: *DeclarationsIterator) void {
|
||||
var depth: usize = 0;
|
||||
var saw_block = false;
|
||||
|
||||
while (true) {
|
||||
const peeked = self.stream.peek() orelse return;
|
||||
if (!saw_block and isSemicolon(peeked.token) and depth == 0) {
|
||||
_ = self.stream.next();
|
||||
return;
|
||||
}
|
||||
|
||||
const span = self.stream.next() orelse return;
|
||||
if (isWhitespaceOrComment(span.token)) continue;
|
||||
|
||||
if (isBlockStart(span.token)) {
|
||||
depth += 1;
|
||||
saw_block = true;
|
||||
} else if (isBlockEnd(span.token)) {
|
||||
if (depth > 0) depth -= 1;
|
||||
if (saw_block and depth == 0) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn skipInvalidDeclaration(self: *DeclarationsIterator) void {
|
||||
var depth: usize = 0;
|
||||
|
||||
while (self.stream.peek()) |peeked| {
|
||||
if (isSemicolon(peeked.token) and depth == 0) {
|
||||
_ = self.stream.next();
|
||||
return;
|
||||
}
|
||||
|
||||
const span = self.stream.next() orelse return;
|
||||
if (isWhitespaceOrComment(span.token)) continue;
|
||||
updateDepth(span.token, &depth);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fn isWhitespaceOrComment(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.white_space, .comment => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isSemicolon(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.semicolon => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isColon(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.colon => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isBlockStart(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.curly_bracket_block, .square_bracket_block, .parenthesis_block, .function => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isBlockEnd(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.close_curly_bracket, .close_parenthesis, .close_square_bracket => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn updateDepth(token: Tokenizer.Token, depth: *usize) void {
|
||||
if (isBlockStart(token)) {
|
||||
depth.* += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBlockEnd(token)) {
|
||||
if (depth.* > 0) depth.* -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn isImportantPair(prev_sig: ?TokenSpan, last_sig: TokenSpan) bool {
|
||||
if (!isIdentImportant(last_sig.token)) return false;
|
||||
const prev = prev_sig orelse return false;
|
||||
return isBang(prev.token);
|
||||
}
|
||||
|
||||
fn isIdentImportant(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.ident => |name| std.ascii.eqlIgnoreCase(name, "important"),
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn isBang(token: Tokenizer.Token) bool {
|
||||
return switch (token) {
|
||||
.delim => |c| c == '!',
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
218
src/browser/css/README.md
Normal file
218
src/browser/css/README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# css
|
||||
|
||||
Lightpanda css implements CSS selectors parsing and matching in Zig.
|
||||
This package is a port of the Go lib [andybalholm/cascadia](https://github.com/andybalholm/cascadia).
|
||||
|
||||
## Usage
|
||||
|
||||
### Query parser
|
||||
|
||||
```zig
|
||||
const css = @import("css.zig");
|
||||
|
||||
const selector = try css.parse(alloc, "h1", .{});
|
||||
defer selector.deinit(alloc);
|
||||
```
|
||||
|
||||
### DOM tree match
|
||||
|
||||
The lib expects a `Node` interface implementation to match your DOM tree.
|
||||
|
||||
```zig
|
||||
pub const Node = struct {
|
||||
pub fn firstChild(_: Node) !?Node {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn lastChild(_: Node) !?Node {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn nextSibling(_: Node) !?Node {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn prevSibling(_: Node) !?Node {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn parent(_: Node) !?Node {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn isElement(_: Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isDocument(_: Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isComment(_: Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isText(_: Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isEmptyText(_: Node) !bool {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn tag(_: Node) ![]const u8 {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn attr(_: Node, _: []const u8) !?[]const u8 {
|
||||
return error.TODO;
|
||||
}
|
||||
|
||||
pub fn eql(_: Node, _: Node) bool {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
You also need do define a `Matcher` implementing a `match` function to
|
||||
accumulate the results.
|
||||
|
||||
```zig
|
||||
const Matcher = struct {
|
||||
const Nodes = std.ArrayList(Node);
|
||||
|
||||
nodes: Nodes,
|
||||
|
||||
fn init(alloc: std.mem.Allocator) Matcher {
|
||||
return .{ .nodes = Nodes.init(alloc) };
|
||||
}
|
||||
|
||||
fn deinit(m: *Matcher) void {
|
||||
m.nodes.deinit();
|
||||
}
|
||||
|
||||
pub fn match(m: *Matcher, n: Node) !void {
|
||||
try m.nodes.append(n);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Then you can use the lib itself.
|
||||
|
||||
```zig
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
try css.matchAll(selector, node, &matcher);
|
||||
_ = try css.matchFirst(selector, node, &matcher); // returns true if a node matched.
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
* [x] parse query selector
|
||||
* [x] `matchAll`
|
||||
* [x] `matchFirst`
|
||||
* [ ] specificity
|
||||
|
||||
### Selectors implemented
|
||||
|
||||
#### Selectors
|
||||
|
||||
* [x] Class selectors
|
||||
* [x] Id selectors
|
||||
* [x] Type selectors
|
||||
* [x] Universal selectors
|
||||
* [ ] Nesting selectors
|
||||
|
||||
#### Combinators
|
||||
|
||||
* [x] Child combinator
|
||||
* [ ] Column combinator
|
||||
* [x] Descendant combinator
|
||||
* [ ] Namespace combinator
|
||||
* [x] Next-sibling combinator
|
||||
* [x] Selector list combinator
|
||||
* [x] Subsequent-sibling combinator
|
||||
|
||||
#### Attribute
|
||||
|
||||
* [x] `[attr]`
|
||||
* [x] `[attr=value]`
|
||||
* [x] `[attr|=value]`
|
||||
* [x] `[attr^=value]`
|
||||
* [x] `[attr$=value]`
|
||||
* [ ] `[attr*=value]`
|
||||
* [x] `[attr operator value i]`
|
||||
* [ ] `[attr operator value s]`
|
||||
|
||||
#### Pseudo classes
|
||||
|
||||
* [ ] `:active`
|
||||
* [ ] `:any-link`
|
||||
* [ ] `:autofill`
|
||||
* [ ] `:blank Experimental`
|
||||
* [x] `:checked`
|
||||
* [ ] `:current Experimental`
|
||||
* [ ] `:default`
|
||||
* [ ] `:defined`
|
||||
* [ ] `:dir() Experimental`
|
||||
* [x] `:disabled`
|
||||
* [x] `:empty`
|
||||
* [x] `:enabled`
|
||||
* [ ] `:first`
|
||||
* [x] `:first-child`
|
||||
* [x] `:first-of-type`
|
||||
* [ ] `:focus`
|
||||
* [ ] `:focus-visible`
|
||||
* [ ] `:focus-within`
|
||||
* [ ] `:fullscreen`
|
||||
* [ ] `:future Experimental`
|
||||
* [x] `:has() Experimental`
|
||||
* [ ] `:host`
|
||||
* [ ] `:host()`
|
||||
* [ ] `:host-context() Experimental`
|
||||
* [ ] `:hover`
|
||||
* [ ] `:indeterminate`
|
||||
* [ ] `:in-range`
|
||||
* [ ] `:invalid`
|
||||
* [ ] `:is()`
|
||||
* [x] `:lang()`
|
||||
* [x] `:last-child`
|
||||
* [x] `:last-of-type`
|
||||
* [ ] `:left`
|
||||
* [x] `:link`
|
||||
* [ ] `:local-link Experimental`
|
||||
* [ ] `:modal`
|
||||
* [x] `:not()`
|
||||
* [x] `:nth-child()`
|
||||
* [x] `:nth-last-child()`
|
||||
* [x] `:nth-last-of-type()`
|
||||
* [x] `:nth-of-type()`
|
||||
* [x] `:only-child`
|
||||
* [x] `:only-of-type`
|
||||
* [ ] `:optional`
|
||||
* [ ] `:out-of-range`
|
||||
* [ ] `:past Experimental`
|
||||
* [ ] `:paused`
|
||||
* [ ] `:picture-in-picture`
|
||||
* [ ] `:placeholder-shown`
|
||||
* [ ] `:playing`
|
||||
* [ ] `:read-only`
|
||||
* [ ] `:read-write`
|
||||
* [ ] `:required`
|
||||
* [ ] `:right`
|
||||
* [x] `:root`
|
||||
* [ ] `:scope`
|
||||
* [ ] `:state() Experimental`
|
||||
* [ ] `:target`
|
||||
* [ ] `:target-within Experimental`
|
||||
* [ ] `:user-invalid Experimental`
|
||||
* [ ] `:valid`
|
||||
* [ ] `:visited`
|
||||
* [ ] `:where()`
|
||||
* [ ] `:contains()`
|
||||
* [ ] `:containsown()`
|
||||
* [ ] `:matched()`
|
||||
* [ ] `:matchesown()`
|
||||
* [x] `:root`
|
||||
|
||||
@@ -1,825 +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/>.
|
||||
|
||||
//! This file implements the tokenization step defined in the CSS Syntax Module Level 3 specification.
|
||||
//!
|
||||
//! The algorithm accepts a valid UTF-8 string and returns a stream of tokens.
|
||||
//! The tokenization step never fails, even for complete gibberish.
|
||||
//! Validity must then be checked by the parser.
|
||||
//!
|
||||
//! NOTE: The tokenizer is not thread-safe and does not own any memory, and does not check the validity of utf8.
|
||||
//!
|
||||
//! See spec for more info: https://drafts.csswg.org/css-syntax/#tokenization
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
|
||||
const Tokenizer = @This();
|
||||
|
||||
pub const Token = union(enum) {
|
||||
/// A `<ident-token>`
|
||||
ident: []const u8,
|
||||
|
||||
/// A `<function-token>`
|
||||
///
|
||||
/// The value (name) does not include the `(` marker.
|
||||
function: []const u8,
|
||||
|
||||
/// A `<at-keyword-token>`
|
||||
///
|
||||
/// The value does not include the `@` marker.
|
||||
at_keyword: []const u8,
|
||||
|
||||
/// A `<hash-token>` with the type flag set to "id"
|
||||
///
|
||||
/// The value does not include the `#` marker.
|
||||
id_hash: []const u8, // Hash that is a valid ID selector.
|
||||
|
||||
/// A `<hash-token>` with the type flag set to "unrestricted"
|
||||
///
|
||||
/// The value does not include the `#` marker.
|
||||
unrestricted_hash: []const u8,
|
||||
|
||||
/// A `<string-token>`
|
||||
///
|
||||
/// The value does not include the quotes.
|
||||
string: []const u8,
|
||||
|
||||
/// A `<bad-string-token>`
|
||||
///
|
||||
/// This token always indicates a parse error.
|
||||
bad_string: []const u8,
|
||||
|
||||
/// A `<url-token>`
|
||||
///
|
||||
/// The value does not include the `url(` `)` markers. Note that `url( <string-token> )` is represented by a
|
||||
/// `Function` token.
|
||||
url: []const u8,
|
||||
|
||||
/// A `<bad-url-token>`
|
||||
///
|
||||
/// This token always indicates a parse error.
|
||||
bad_url: []const u8,
|
||||
|
||||
/// A `<delim-token>`
|
||||
delim: u8,
|
||||
|
||||
/// A `<number-token>`
|
||||
number: struct {
|
||||
/// Whether the number had a `+` or `-` sign.
|
||||
///
|
||||
/// This is used is some cases like the <An+B> micro syntax. (See the `parse_nth` function.)
|
||||
has_sign: bool,
|
||||
|
||||
/// If the origin source did not include a fractional part, the value as an integer.
|
||||
int_value: ?i32,
|
||||
|
||||
/// The value as a float
|
||||
value: f32,
|
||||
},
|
||||
|
||||
/// A `<percentage-token>`
|
||||
percentage: struct {
|
||||
/// Whether the number had a `+` or `-` sign.
|
||||
has_sign: bool,
|
||||
|
||||
/// If the origin source did not include a fractional part, the value as an integer.
|
||||
/// It is **not** divided by 100.
|
||||
int_value: ?i32,
|
||||
|
||||
/// The value as a float, divided by 100 so that the nominal range is 0.0 to 1.0.
|
||||
unit_value: f32,
|
||||
},
|
||||
|
||||
/// A `<dimension-token>`
|
||||
dimension: struct {
|
||||
/// Whether the number had a `+` or `-` sign.
|
||||
///
|
||||
/// This is used is some cases like the <An+B> micro syntax. (See the `parse_nth` function.)
|
||||
has_sign: bool,
|
||||
|
||||
/// If the origin source did not include a fractional part, the value as an integer.
|
||||
int_value: ?i32,
|
||||
|
||||
/// The value as a float
|
||||
value: f32,
|
||||
|
||||
/// The unit, e.g. "px" in `12px`
|
||||
unit: []const u8,
|
||||
},
|
||||
|
||||
/// A `<unicode-range-token>`
|
||||
unicode_range: struct { bgn: u32, end: i32 },
|
||||
|
||||
/// A `<whitespace-token>`
|
||||
white_space: []const u8,
|
||||
|
||||
/// A `<!--` `<CDO-token>`
|
||||
cdo,
|
||||
|
||||
/// A `-->` `<CDC-token>`
|
||||
cdc,
|
||||
|
||||
/// A `:` `<colon-token>`
|
||||
colon, // :
|
||||
|
||||
/// A `;` `<semicolon-token>`
|
||||
semicolon, // ;
|
||||
|
||||
/// A `,` `<comma-token>`
|
||||
comma, // ,
|
||||
|
||||
/// A `<[-token>`
|
||||
square_bracket_block,
|
||||
|
||||
/// A `<]-token>`
|
||||
///
|
||||
/// When obtained from one of the `Parser::next*` methods,
|
||||
/// this token is always unmatched and indicates a parse error.
|
||||
close_square_bracket,
|
||||
|
||||
/// A `<(-token>`
|
||||
parenthesis_block,
|
||||
|
||||
/// A `<)-token>`
|
||||
///
|
||||
/// When obtained from one of the `Parser::next*` methods,
|
||||
/// this token is always unmatched and indicates a parse error.
|
||||
close_parenthesis,
|
||||
|
||||
/// A `<{-token>`
|
||||
curly_bracket_block,
|
||||
|
||||
/// A `<}-token>`
|
||||
///
|
||||
/// When obtained from one of the `Parser::next*` methods,
|
||||
/// this token is always unmatched and indicates a parse error.
|
||||
close_curly_bracket,
|
||||
|
||||
/// A comment.
|
||||
///
|
||||
/// The CSS Syntax spec does not generate tokens for comments,
|
||||
/// But we do for simplicity of the interface.
|
||||
///
|
||||
/// The value does not include the `/*` `*/` markers.
|
||||
comment: []const u8,
|
||||
};
|
||||
|
||||
input: []const u8,
|
||||
|
||||
/// Counted in bytes, not code points. From 0.
|
||||
position: usize = 0,
|
||||
|
||||
// If true, the input has at least `n` bytes left *after* the current one.
|
||||
// That is, `Lexer.byteAt(n)` will not panic.
|
||||
fn hasAtLeast(self: *const Tokenizer, n: usize) bool {
|
||||
return self.position + n < self.input.len;
|
||||
}
|
||||
|
||||
fn isEof(self: *const Tokenizer) bool {
|
||||
return !self.hasAtLeast(0);
|
||||
}
|
||||
|
||||
fn byteAt(self: *const Tokenizer, offset: usize) u8 {
|
||||
return self.input[self.position + offset];
|
||||
}
|
||||
|
||||
// Assumes non-EOF
|
||||
fn nextByteUnchecked(self: *const Tokenizer) u8 {
|
||||
return self.byteAt(0);
|
||||
}
|
||||
|
||||
fn nextByte(self: *const Tokenizer) ?u8 {
|
||||
return if (self.isEof())
|
||||
null
|
||||
else
|
||||
self.input[self.position];
|
||||
}
|
||||
|
||||
fn startsWith(self: *const Tokenizer, needle: []const u8) bool {
|
||||
return std.mem.startsWith(u8, self.input[self.position..], needle);
|
||||
}
|
||||
|
||||
fn slice(self: *const Tokenizer, start: usize, end: usize) []const u8 {
|
||||
return self.input[start..end];
|
||||
}
|
||||
|
||||
fn sliceFrom(self: *const Tokenizer, start_pos: usize) []const u8 {
|
||||
return self.slice(start_pos, self.position);
|
||||
}
|
||||
|
||||
// Advance over N bytes in the input. This function can advance
|
||||
// over ASCII bytes (excluding newlines), or UTF-8 sequence
|
||||
// leaders (excluding leaders for 4-byte sequences).
|
||||
fn advance(self: *Tokenizer, n: usize) void {
|
||||
if (builtin.mode == .Debug) {
|
||||
// Each byte must either be an ASCII byte or a sequence leader,
|
||||
// but not a 4-byte leader; also newlines are rejected.
|
||||
for (0..n) |i| {
|
||||
const b = self.byteAt(i);
|
||||
assert(b != '\r' and b != '\n' and b != '\x0C');
|
||||
assert(b <= 0x7F or (b & 0xF0 != 0xF0 and b & 0xC0 != 0x80));
|
||||
}
|
||||
}
|
||||
self.position += n;
|
||||
}
|
||||
|
||||
fn hasNewlineAt(self: *const Tokenizer, offset: usize) bool {
|
||||
if (!self.hasAtLeast(offset)) return false;
|
||||
|
||||
return switch (self.byteAt(offset)) {
|
||||
'\n', '\r', '\x0C' => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn hasNonAsciiAt(self: *const Tokenizer, offset: usize) bool {
|
||||
if (!self.hasAtLeast(offset)) return false;
|
||||
|
||||
const byte = self.byteAt(offset);
|
||||
const len_utf8 = std.unicode.utf8ByteSequenceLength(byte) catch return false;
|
||||
|
||||
if (!self.hasAtLeast(offset + len_utf8 - 1)) return false;
|
||||
|
||||
const start = self.position + offset;
|
||||
const bytes = self.slice(start, start + len_utf8);
|
||||
|
||||
const codepoint = std.unicode.utf8Decode(bytes) catch return false;
|
||||
|
||||
// https://drafts.csswg.org/css-syntax/#non-ascii-ident-code-point
|
||||
return switch (codepoint) {
|
||||
'\u{00B7}', '\u{200C}', '\u{200D}', '\u{203F}', '\u{2040}' => true,
|
||||
'\u{00C0}'...'\u{00D6}' => true,
|
||||
'\u{00D8}'...'\u{00F6}' => true,
|
||||
'\u{00F8}'...'\u{037D}' => true,
|
||||
'\u{037F}'...'\u{1FFF}' => true,
|
||||
'\u{2070}'...'\u{218F}' => true,
|
||||
'\u{2C00}'...'\u{2FEF}' => true,
|
||||
'\u{3001}'...'\u{D7FF}' => true,
|
||||
'\u{F900}'...'\u{FDCF}' => true,
|
||||
'\u{FDF0}'...'\u{FFFD}' => true,
|
||||
else => codepoint >= '\u{10000}',
|
||||
};
|
||||
}
|
||||
|
||||
fn isIdentStart(self: *Tokenizer) bool {
|
||||
if (self.isEof()) return false;
|
||||
|
||||
var b = self.nextByteUnchecked();
|
||||
if (b == '-') {
|
||||
b = if (self.hasAtLeast(1)) self.byteAt(1) else return false;
|
||||
}
|
||||
|
||||
return switch (b) {
|
||||
'a'...'z', 'A'...'Z', '_', 0x0 => true,
|
||||
'\\' => !self.hasNewlineAt(1),
|
||||
else => b > 0x7F, // not is ascii
|
||||
};
|
||||
}
|
||||
|
||||
fn consumeChar(self: *Tokenizer) void {
|
||||
const byte = self.nextByteUnchecked();
|
||||
const len_utf8 = std.unicode.utf8ByteSequenceLength(byte) catch 1;
|
||||
self.position += len_utf8;
|
||||
}
|
||||
|
||||
// Given that a newline has been seen, advance over the newline
|
||||
// and update the state.
|
||||
fn consumeNewline(self: *Tokenizer) void {
|
||||
const byte = self.nextByteUnchecked();
|
||||
assert(byte == '\r' or byte == '\n' or byte == '\x0C');
|
||||
|
||||
self.position += 1;
|
||||
if (byte == '\r' and self.nextByte() == '\n') {
|
||||
self.position += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn consumeWhiteSpace(self: *Tokenizer, newline: bool) Token {
|
||||
const start_position = self.position;
|
||||
if (newline) {
|
||||
self.consumeNewline();
|
||||
} else {
|
||||
self.advance(1);
|
||||
}
|
||||
while (!self.isEof()) {
|
||||
const b = self.nextByteUnchecked();
|
||||
switch (b) {
|
||||
' ', '\t' => {
|
||||
self.advance(1);
|
||||
},
|
||||
'\n', '\x0C', '\r' => {
|
||||
self.consumeNewline();
|
||||
},
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
return .{ .white_space = self.sliceFrom(start_position) };
|
||||
}
|
||||
|
||||
fn consumeComment(self: *Tokenizer) []const u8 {
|
||||
self.advance(2); // consume "/*"
|
||||
const start_position = self.position;
|
||||
while (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'*' => {
|
||||
const end_position = self.position;
|
||||
self.advance(1);
|
||||
if (self.nextByte() == '/') {
|
||||
self.advance(1);
|
||||
return self.slice(start_position, end_position);
|
||||
}
|
||||
},
|
||||
'\n', '\x0C', '\r' => {
|
||||
self.consumeNewline();
|
||||
},
|
||||
0x0 => self.advance(1),
|
||||
else => self.consumeChar(),
|
||||
}
|
||||
}
|
||||
return self.sliceFrom(start_position);
|
||||
}
|
||||
|
||||
fn byteToHexDigit(b: u8) ?u32 {
|
||||
return switch (b) {
|
||||
'0'...'9' => b - '0',
|
||||
'a'...'f' => b - 'a' + 10,
|
||||
'A'...'F' => b - 'A' + 10,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn byteToDecimalDigit(b: u8) ?u32 {
|
||||
return if (std.ascii.isDigit(b)) b - '0' else null;
|
||||
}
|
||||
|
||||
// (value, number of digits up to 6)
|
||||
fn consumeHexDigits(self: *Tokenizer) void {
|
||||
var value: u32 = 0;
|
||||
var digits: u32 = 0;
|
||||
|
||||
while (digits < 6 and !self.isEof()) {
|
||||
if (byteToHexDigit(self.nextByteUnchecked())) |digit| {
|
||||
value = value * 16 + digit;
|
||||
digits += 1;
|
||||
self.advance(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_ = &value;
|
||||
}
|
||||
|
||||
// Assumes that the U+005C REVERSE SOLIDUS (\) has already been consumed
|
||||
// and that the next input character has already been verified
|
||||
// to not be a newline.
|
||||
fn consumeEscape(self: *Tokenizer) void {
|
||||
if (self.isEof())
|
||||
return; // Escaped EOF
|
||||
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'0'...'9', 'A'...'F', 'a'...'f' => {
|
||||
consumeHexDigits(self);
|
||||
|
||||
if (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
' ', '\t' => {
|
||||
self.advance(1);
|
||||
},
|
||||
'\n', '\x0C', '\r' => {
|
||||
self.consumeNewline();
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
},
|
||||
else => self.consumeChar(),
|
||||
}
|
||||
}
|
||||
|
||||
/// https://drafts.csswg.org/css-syntax/#consume-string-token
|
||||
fn consumeString(self: *Tokenizer, single_quote: bool) Token {
|
||||
self.advance(1); // Skip the initial quote
|
||||
|
||||
// start_pos is at code point boundary, after " or '
|
||||
const start_pos = self.position;
|
||||
|
||||
while (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'"' => {
|
||||
if (!single_quote) {
|
||||
const value = self.sliceFrom(start_pos);
|
||||
self.advance(1);
|
||||
return .{ .string = value };
|
||||
}
|
||||
self.advance(1);
|
||||
},
|
||||
'\'' => {
|
||||
if (single_quote) {
|
||||
const value = self.sliceFrom(start_pos);
|
||||
self.advance(1);
|
||||
return .{ .string = value };
|
||||
}
|
||||
self.advance(1);
|
||||
},
|
||||
'\n', '\r', '\x0C' => {
|
||||
return .{ .bad_string = self.sliceFrom(start_pos) };
|
||||
},
|
||||
'\\' => {
|
||||
self.advance(1);
|
||||
if (self.isEof())
|
||||
continue; // escaped EOF, do nothing.
|
||||
|
||||
switch (self.nextByteUnchecked()) {
|
||||
// Escaped newline
|
||||
'\n', '\x0C', '\r' => self.consumeNewline(),
|
||||
|
||||
// Spec calls for replacing escape sequences with characters,
|
||||
// but this would require allocating a new string.
|
||||
// Therefore, we leave it as is and let the parser handle the escaping.
|
||||
else => self.consumeEscape(),
|
||||
}
|
||||
},
|
||||
else => self.consumeChar(),
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .string = self.sliceFrom(start_pos) };
|
||||
}
|
||||
|
||||
fn consumeName(self: *Tokenizer) []const u8 {
|
||||
// start_pos is the end of the previous token, therefore at a code point boundary
|
||||
const start_pos = self.position;
|
||||
|
||||
while (!self.isEof()) {
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'a'...'z', 'A'...'Z', '0'...'9', '_', '-' => self.advance(1),
|
||||
'\\' => {
|
||||
if (self.hasNewlineAt(1)) {
|
||||
break;
|
||||
}
|
||||
|
||||
self.advance(1);
|
||||
self.consumeEscape();
|
||||
},
|
||||
0x0 => self.advance(1),
|
||||
'\x80'...'\xFF' => {
|
||||
// Non-ASCII: advance over the complete UTF-8 code point in one step.
|
||||
// Using consumeChar() instead of advance(1) ensures we never land on
|
||||
// a continuation byte, which advance() asserts against.
|
||||
self.consumeChar();
|
||||
},
|
||||
else => {
|
||||
if (self.hasNonAsciiAt(0)) {
|
||||
self.consumeChar();
|
||||
} else {
|
||||
break; // ASCII
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return self.sliceFrom(start_pos);
|
||||
}
|
||||
|
||||
fn consumeMark(self: *Tokenizer) Token {
|
||||
const byte = self.nextByteUnchecked();
|
||||
self.advance(1);
|
||||
return switch (byte) {
|
||||
',' => .comma,
|
||||
':' => .colon,
|
||||
';' => .semicolon,
|
||||
'(' => .parenthesis_block,
|
||||
')' => .close_parenthesis,
|
||||
'{' => .curly_bracket_block,
|
||||
'}' => .close_curly_bracket,
|
||||
'[' => .square_bracket_block,
|
||||
']' => .close_square_bracket,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
fn consumeNumeric(self: *Tokenizer) Token {
|
||||
// Parse [+-]?\d*(\.\d+)?([eE][+-]?\d+)?
|
||||
// But this is always called so that there is at least one digit in \d*(\.\d+)?
|
||||
|
||||
// Do all the math in f64 so that large numbers overflow to +/-inf
|
||||
// and i32::{MIN, MAX} are within range.
|
||||
|
||||
var sign: f64 = 1.0;
|
||||
var has_sign = false;
|
||||
switch (self.nextByteUnchecked()) {
|
||||
'+' => {
|
||||
has_sign = true;
|
||||
},
|
||||
'-' => {
|
||||
has_sign = true;
|
||||
sign = -1.0;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
if (has_sign) {
|
||||
self.advance(1);
|
||||
}
|
||||
|
||||
var is_integer = true;
|
||||
var integral_part: f64 = 0.0;
|
||||
var fractional_part: f64 = 0.0;
|
||||
|
||||
while (!self.isEof()) {
|
||||
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
|
||||
integral_part = integral_part * 10.0 + @as(f64, @floatFromInt(digit));
|
||||
self.advance(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (self.hasAtLeast(1) and self.nextByteUnchecked() == '.' and std.ascii.isDigit(self.byteAt(1))) {
|
||||
is_integer = false;
|
||||
self.advance(1); // Consume '.'
|
||||
|
||||
var factor: f64 = 0.1;
|
||||
while (!self.isEof()) {
|
||||
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
|
||||
fractional_part += @as(f64, @floatFromInt(digit)) * factor;
|
||||
factor *= 0.1;
|
||||
self.advance(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var value = sign * (integral_part + fractional_part);
|
||||
|
||||
blk: {
|
||||
const e = self.nextByte() orelse break :blk;
|
||||
if (e != 'e' and e != 'E') break :blk;
|
||||
|
||||
var mul: f64 = 1.0;
|
||||
|
||||
if (self.hasAtLeast(2) and (self.byteAt(1) == '+' or self.byteAt(1) == '-') and std.ascii.isDigit(self.byteAt(2))) {
|
||||
mul = switch (self.byteAt(1)) {
|
||||
'-' => -1.0,
|
||||
'+' => 1.0,
|
||||
else => unreachable,
|
||||
};
|
||||
|
||||
self.advance(2);
|
||||
} else if (self.hasAtLeast(2) and std.ascii.isDigit(self.byteAt(2))) {
|
||||
self.advance(1);
|
||||
} else {
|
||||
break :blk;
|
||||
}
|
||||
|
||||
is_integer = false;
|
||||
|
||||
var exponent: f64 = 0.0;
|
||||
while (!self.isEof()) {
|
||||
if (byteToDecimalDigit(self.nextByteUnchecked())) |digit| {
|
||||
exponent = exponent * 10.0 + @as(f64, @floatFromInt(digit));
|
||||
self.advance(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
value *= std.math.pow(f64, 10.0, mul * exponent);
|
||||
}
|
||||
|
||||
const int_value: ?i32 = if (is_integer) blk: {
|
||||
if (value >= std.math.maxInt(i32)) {
|
||||
break :blk std.math.maxInt(i32);
|
||||
}
|
||||
|
||||
if (value <= std.math.minInt(i32)) {
|
||||
break :blk std.math.minInt(i32);
|
||||
}
|
||||
|
||||
break :blk @as(i32, @intFromFloat(value));
|
||||
} else null;
|
||||
|
||||
if (!self.isEof() and self.nextByteUnchecked() == '%') {
|
||||
self.advance(1);
|
||||
|
||||
return .{ .percentage = .{
|
||||
.has_sign = has_sign,
|
||||
.int_value = int_value,
|
||||
.unit_value = @as(f32, @floatCast(value / 100.0)),
|
||||
} };
|
||||
}
|
||||
|
||||
if (isIdentStart(self)) {
|
||||
return .{ .dimension = .{
|
||||
.has_sign = has_sign,
|
||||
.int_value = int_value,
|
||||
.value = @as(f32, @floatCast(value)),
|
||||
.unit = consumeName(self),
|
||||
} };
|
||||
}
|
||||
|
||||
return .{ .number = .{
|
||||
.has_sign = has_sign,
|
||||
.int_value = int_value,
|
||||
.value = @as(f32, @floatCast(value)),
|
||||
} };
|
||||
}
|
||||
|
||||
fn consumeUnquotedUrl(self: *Tokenizer) ?Token {
|
||||
// TODO: true url parser
|
||||
if (self.nextByte()) |it| {
|
||||
return self.consumeString(it == '\'');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn consumeIdentLike(self: *Tokenizer) Token {
|
||||
const value = self.consumeName();
|
||||
|
||||
if (!self.isEof() and self.nextByteUnchecked() == '(') {
|
||||
self.advance(1);
|
||||
|
||||
if (std.ascii.eqlIgnoreCase(value, "url")) {
|
||||
if (self.consumeUnquotedUrl()) |result| {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .function = value };
|
||||
}
|
||||
|
||||
return .{ .ident = value };
|
||||
}
|
||||
|
||||
pub fn next(self: *Tokenizer) ?Token {
|
||||
if (self.isEof()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const b = self.nextByteUnchecked();
|
||||
return switch (b) {
|
||||
// Consume comments
|
||||
'/' => {
|
||||
if (self.startsWith("/*")) {
|
||||
return .{ .comment = self.consumeComment() };
|
||||
} else {
|
||||
self.advance(1);
|
||||
return .{ .delim = '/' };
|
||||
}
|
||||
},
|
||||
|
||||
// Consume marks
|
||||
'(', ')', '{', '}', '[', ']', ',', ':', ';' => {
|
||||
return self.consumeMark();
|
||||
},
|
||||
|
||||
// Consume as much whitespace as possible. Return a <whitespace-token>.
|
||||
' ', '\t' => self.consumeWhiteSpace(false),
|
||||
'\n', '\x0C', '\r' => self.consumeWhiteSpace(true),
|
||||
|
||||
// Consume a string token and return it.
|
||||
'"' => self.consumeString(false),
|
||||
'\'' => self.consumeString(true),
|
||||
|
||||
'0'...'9' => self.consumeNumeric(),
|
||||
'a'...'z', 'A'...'Z', '_', 0x0 => self.consumeIdentLike(),
|
||||
|
||||
'+' => {
|
||||
if ((self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) or
|
||||
(self.hasAtLeast(2) and self.byteAt(1) == '.' and std.ascii.isDigit(self.byteAt(2))))
|
||||
{
|
||||
return self.consumeNumeric();
|
||||
}
|
||||
self.advance(1);
|
||||
return .{ .delim = '+' };
|
||||
},
|
||||
'-' => {
|
||||
if ((self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) or
|
||||
(self.hasAtLeast(2) and self.byteAt(1) == '.' and std.ascii.isDigit(self.byteAt(2))))
|
||||
{
|
||||
return self.consumeNumeric();
|
||||
}
|
||||
|
||||
if (self.startsWith("-->")) {
|
||||
self.advance(3);
|
||||
return .cdc;
|
||||
}
|
||||
|
||||
if (isIdentStart(self)) {
|
||||
return self.consumeIdentLike();
|
||||
}
|
||||
|
||||
self.advance(1);
|
||||
return .{ .delim = '-' };
|
||||
},
|
||||
'.' => {
|
||||
if (self.hasAtLeast(1) and std.ascii.isDigit(self.byteAt(1))) {
|
||||
return self.consumeNumeric();
|
||||
}
|
||||
self.advance(1);
|
||||
return .{ .delim = '.' };
|
||||
},
|
||||
|
||||
// Consume hash token
|
||||
'#' => {
|
||||
self.advance(1);
|
||||
if (self.isIdentStart()) {
|
||||
return .{ .id_hash = self.consumeName() };
|
||||
}
|
||||
if (self.nextByte()) |it| {
|
||||
switch (it) {
|
||||
// Any other valid case here already resulted in IDHash.
|
||||
'0'...'9', '-' => return .{ .unrestricted_hash = self.consumeName() },
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
return .{ .delim = '#' };
|
||||
},
|
||||
|
||||
// Consume at-rules
|
||||
'@' => {
|
||||
self.advance(1);
|
||||
return if (isIdentStart(self))
|
||||
.{ .at_keyword = consumeName(self) }
|
||||
else
|
||||
.{ .delim = '@' };
|
||||
},
|
||||
|
||||
'<' => {
|
||||
if (self.startsWith("<!--")) {
|
||||
self.advance(4);
|
||||
return .cdo;
|
||||
} else {
|
||||
self.advance(1);
|
||||
return .{ .delim = '<' };
|
||||
}
|
||||
},
|
||||
|
||||
'\\' => {
|
||||
if (!self.hasNewlineAt(1)) {
|
||||
return self.consumeIdentLike();
|
||||
}
|
||||
|
||||
self.advance(1);
|
||||
return .{ .delim = '\\' };
|
||||
},
|
||||
|
||||
else => {
|
||||
if (b > 0x7F) { // not is ascii
|
||||
return self.consumeIdentLike();
|
||||
}
|
||||
|
||||
self.advance(1);
|
||||
return .{ .delim = b };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn expectTokensEqual(input: []const u8, tokens: []const Token) !void {
|
||||
var lexer = Tokenizer{ .input = input };
|
||||
|
||||
var i: usize = 0;
|
||||
while (lexer.next()) |token| : (i += 1) {
|
||||
assert(i < tokens.len);
|
||||
try testing.expectEqualDeep(tokens[i], token);
|
||||
}
|
||||
|
||||
try testing.expectEqual(i, tokens.len);
|
||||
try testing.expectEqualDeep(null, lexer.next());
|
||||
}
|
||||
|
||||
test "smoke" {
|
||||
try expectTokensEqual(
|
||||
\\.lightpanda {color:red;}
|
||||
, &.{
|
||||
.{ .delim = '.' },
|
||||
.{ .ident = "lightpanda" },
|
||||
.{ .white_space = " " },
|
||||
.curly_bracket_block,
|
||||
.{ .ident = "color" },
|
||||
.colon,
|
||||
.{ .ident = "red" },
|
||||
.semicolon,
|
||||
.close_curly_bracket,
|
||||
});
|
||||
}
|
||||
176
src/browser/css/css.zig
Normal file
176
src/browser/css/css.zig
Normal file
@@ -0,0 +1,176 @@
|
||||
// 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/>.
|
||||
|
||||
// CSS Selector parser and query
|
||||
// This package is a rewrite in Zig of Cascadia CSS Selector parser.
|
||||
// see https://github.com/andybalholm/cascadia
|
||||
const std = @import("std");
|
||||
const Selector = @import("selector.zig").Selector;
|
||||
const parser = @import("parser.zig");
|
||||
|
||||
// parse parse a selector string and returns the parsed result or an error.
|
||||
pub fn parse(alloc: std.mem.Allocator, s: []const u8, opts: parser.ParseOptions) parser.ParseError!Selector {
|
||||
var p = parser.Parser{ .s = s, .i = 0, .opts = opts };
|
||||
return p.parse(alloc);
|
||||
}
|
||||
|
||||
// matchFirst call m.match with the first node that matches the selector s, from the
|
||||
// descendants of n and returns true. If none matches, it returns false.
|
||||
pub fn matchFirst(s: Selector, node: anytype, m: anytype) !bool {
|
||||
var c = try node.firstChild();
|
||||
while (true) {
|
||||
if (c == null) break;
|
||||
|
||||
if (try s.match(c.?)) {
|
||||
try m.match(c.?);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (try matchFirst(s, c.?, m)) return true;
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// matchAll call m.match with the all the nodes that matches the selector s, from the
|
||||
// descendants of n.
|
||||
pub fn matchAll(s: Selector, node: anytype, m: anytype) !void {
|
||||
var c = try node.firstChild();
|
||||
while (true) {
|
||||
if (c == null) break;
|
||||
|
||||
if (try s.match(c.?)) try m.match(c.?);
|
||||
try matchAll(s, c.?, m);
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
}
|
||||
|
||||
test "parse" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
const testcases = [_][]const u8{
|
||||
"address",
|
||||
"*",
|
||||
"#foo",
|
||||
"li#t1",
|
||||
"*#t4",
|
||||
".t1",
|
||||
"p.t1",
|
||||
"div.teST",
|
||||
".t1.fail",
|
||||
"p.t1.t2",
|
||||
"p.--t1",
|
||||
"p.--t1.--t2",
|
||||
"p[title]",
|
||||
"div[class=\"red\" i]",
|
||||
"address[title=\"foo\"]",
|
||||
"address[title=\"FoOIgnoRECaSe\" i]",
|
||||
"address[title!=\"foo\"]",
|
||||
"address[title!=\"foo\" i]",
|
||||
"p[title!=\"FooBarUFoo\" i]",
|
||||
"[ \t title ~= foo ]",
|
||||
"p[title~=\"FOO\" i]",
|
||||
"p[title~=toofoo i]",
|
||||
"[title~=\"hello world\"]",
|
||||
"[title~=\"hello\" i]",
|
||||
"[title~=\"hello\" I]",
|
||||
"[lang|=\"en\"]",
|
||||
"[lang|=\"EN\" i]",
|
||||
"[lang|=\"EN\" i]",
|
||||
"[title^=\"foo\"]",
|
||||
"[title^=\"foo\" i]",
|
||||
"[title$=\"bar\"]",
|
||||
"[title$=\"BAR\" i]",
|
||||
"[title*=\"bar\"]",
|
||||
"[title*=\"BaRu\" i]",
|
||||
"[title*=\"BaRu\" I]",
|
||||
"p[class$=\" \"]",
|
||||
"p[class$=\"\"]",
|
||||
"p[class^=\" \"]",
|
||||
"p[class^=\"\"]",
|
||||
"p[class*=\" \"]",
|
||||
"p[class*=\"\"]",
|
||||
"input[name=Sex][value=F]",
|
||||
"table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]",
|
||||
".t1:not(.t2)",
|
||||
"div:not(.t1)",
|
||||
"div:not([class=\"t2\"])",
|
||||
"li:nth-child(odd)",
|
||||
"li:nth-child(even)",
|
||||
"li:nth-child(-n+2)",
|
||||
"li:nth-child(3n+1)",
|
||||
"li:nth-last-child(odd)",
|
||||
"li:nth-last-child(even)",
|
||||
"li:nth-last-child(-n+2)",
|
||||
"li:nth-last-child(3n+1)",
|
||||
"span:first-child",
|
||||
"span:last-child",
|
||||
"p:nth-of-type(2)",
|
||||
"p:nth-last-of-type(2)",
|
||||
"p:last-of-type",
|
||||
"p:first-of-type",
|
||||
"p:only-child",
|
||||
"p:only-of-type",
|
||||
":empty",
|
||||
"div p",
|
||||
"div table p",
|
||||
"div > p",
|
||||
"p ~ p",
|
||||
"p + p",
|
||||
"li, p",
|
||||
"p +/*This is a comment*/ p",
|
||||
"p:contains(\"that wraps\")",
|
||||
"p:containsOwn(\"that wraps\")",
|
||||
":containsOwn(\"inner\")",
|
||||
"p:containsOwn(\"block\")",
|
||||
"div:has(#p1)",
|
||||
"div:has(:containsOwn(\"2\"))",
|
||||
"body :has(:containsOwn(\"2\"))",
|
||||
"body :haschild(:containsOwn(\"2\"))",
|
||||
"p:matches([\\d])",
|
||||
"p:matches([a-z])",
|
||||
"p:matches([a-zA-Z])",
|
||||
"p:matches([^\\d])",
|
||||
"p:matches(^(0|a))",
|
||||
"p:matches(^\\d+$)",
|
||||
"p:not(:matches(^\\d+$))",
|
||||
"div :matchesOwn(^\\d+$)",
|
||||
"[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])",
|
||||
"[href#=(^https:\\/\\/[^\\/]*\\/?news)]",
|
||||
":input",
|
||||
":root",
|
||||
"*:root",
|
||||
"html:nth-child(1)",
|
||||
"*:root:first-child",
|
||||
"*:root:nth-child(1)",
|
||||
"a:not(:root)",
|
||||
"body > *:nth-child(3n+2)",
|
||||
"input:disabled",
|
||||
":disabled",
|
||||
":enabled",
|
||||
"div.class1, div.class2",
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
const s = parse(alloc, tc, .{}) catch |e| {
|
||||
std.debug.print("query {s}", .{tc});
|
||||
return e;
|
||||
};
|
||||
defer s.deinit(alloc);
|
||||
}
|
||||
}
|
||||
102
src/browser/css/libdom.zig
Normal file
102
src/browser/css/libdom.zig
Normal file
@@ -0,0 +1,102 @@
|
||||
// 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");
|
||||
|
||||
// Node implementation with Netsurf Libdom C lib.
|
||||
pub const Node = struct {
|
||||
node: *parser.Node,
|
||||
|
||||
pub fn firstChild(n: Node) !?Node {
|
||||
const c = try parser.nodeFirstChild(n.node);
|
||||
if (c) |cc| return .{ .node = cc };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn lastChild(n: Node) !?Node {
|
||||
const c = try parser.nodeLastChild(n.node);
|
||||
if (c) |cc| return .{ .node = cc };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn nextSibling(n: Node) !?Node {
|
||||
const c = try parser.nodeNextSibling(n.node);
|
||||
if (c) |cc| return .{ .node = cc };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn prevSibling(n: Node) !?Node {
|
||||
const c = try parser.nodePreviousSibling(n.node);
|
||||
if (c) |cc| return .{ .node = cc };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn parent(n: Node) !?Node {
|
||||
const c = try parser.nodeParentNode(n.node);
|
||||
if (c) |cc| return .{ .node = cc };
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn isElement(n: Node) bool {
|
||||
const t = parser.nodeType(n.node) catch return false;
|
||||
return t == .element;
|
||||
}
|
||||
|
||||
pub fn isDocument(n: Node) bool {
|
||||
const t = parser.nodeType(n.node) catch return false;
|
||||
return t == .document;
|
||||
}
|
||||
|
||||
pub fn isComment(n: Node) bool {
|
||||
const t = parser.nodeType(n.node) catch return false;
|
||||
return t == .comment;
|
||||
}
|
||||
|
||||
pub fn isText(n: Node) bool {
|
||||
const t = parser.nodeType(n.node) catch return false;
|
||||
return t == .text;
|
||||
}
|
||||
|
||||
pub fn isEmptyText(n: Node) !bool {
|
||||
const data = try parser.nodeTextContent(n.node);
|
||||
if (data == null) return true;
|
||||
if (data.?.len == 0) return true;
|
||||
|
||||
return std.mem.trim(u8, data.?, &std.ascii.whitespace).len == 0;
|
||||
}
|
||||
|
||||
pub fn tag(n: Node) ![]const u8 {
|
||||
return try parser.nodeName(n.node);
|
||||
}
|
||||
|
||||
pub fn attr(n: Node, key: []const u8) !?[]const u8 {
|
||||
if (!n.isElement()) return null;
|
||||
return try parser.elementGetAttribute(parser.nodeToElement(n.node), key);
|
||||
}
|
||||
|
||||
pub fn eql(a: Node, b: Node) bool {
|
||||
return a.node == b.node;
|
||||
}
|
||||
};
|
||||
326
src/browser/css/libdom_test.zig
Normal file
326
src/browser/css/libdom_test.zig
Normal file
@@ -0,0 +1,326 @@
|
||||
// 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 css = @import("css.zig");
|
||||
const Node = @import("libdom.zig").Node;
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const Matcher = struct {
|
||||
const Nodes = std.ArrayList(Node);
|
||||
|
||||
nodes: Nodes,
|
||||
|
||||
fn init(alloc: std.mem.Allocator) Matcher {
|
||||
return .{ .nodes = Nodes.init(alloc) };
|
||||
}
|
||||
|
||||
fn deinit(m: *Matcher) void {
|
||||
m.nodes.deinit();
|
||||
}
|
||||
|
||||
fn reset(m: *Matcher) void {
|
||||
m.nodes.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
pub fn match(m: *Matcher, n: Node) !void {
|
||||
try m.nodes.append(n);
|
||||
}
|
||||
};
|
||||
|
||||
const Elements = @import("../html/elements.zig");
|
||||
test "matchFirst" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
html: []const u8,
|
||||
exp: usize,
|
||||
}{
|
||||
.{ .q = "address", .html = "<body><address>This address...</address></body>", .exp = 1 },
|
||||
.{ .q = "*", .html = "<!-- comment --><html><head></head><body>text</body></html>", .exp = 1 },
|
||||
.{ .q = "*", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "#foo", .html = "<p id=\"foo\"><p id=\"bar\">", .exp = 1 },
|
||||
.{ .q = "li#t1", .html = "<ul><li id=\"t1\"><p id=\"t1\">", .exp = 1 },
|
||||
.{ .q = ".t3", .html = "<ul><li class=\"t1\"><li class=\"t2 t3\">", .exp = 1 },
|
||||
.{ .q = "*#t4", .html = "<ol><li id=\"t4\"><li id=\"t44\">", .exp = 1 },
|
||||
.{ .q = ".t1", .html = "<ul><li class=\"t1\"><li class=\"t2\">", .exp = 1 },
|
||||
.{ .q = "p.t1", .html = "<p class=\"t1 t2\">", .exp = 1 },
|
||||
.{ .q = "div.teST", .html = "<div class=\"test\">", .exp = 0 },
|
||||
.{ .q = ".t1.fail", .html = "<p class=\"t1 t2\">", .exp = 0 },
|
||||
.{ .q = "p.t1.t2", .html = "<p class=\"t1 t2\">", .exp = 1 },
|
||||
.{ .q = "p.--t1", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
|
||||
.{ .q = "p.--t1.--t2", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
|
||||
.{ .q = "p[title]", .html = "<p><p title=\"title\">", .exp = 1 },
|
||||
.{ .q = "div[class=\"red\" i]", .html = "<div><div class=\"Red\">", .exp = 1 },
|
||||
.{ .q = "address[title=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "address[title=\"FoOIgnoRECaSe\" i]", .html = "<address><address title=\"fooIgnoreCase\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "address[title!=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "address[title!=\"foo\" i]", .html = "<address><address title=\"FOO\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "p[title!=\"FooBarUFoo\" i]", .html = "<p title=\"fooBARuFOO\"><p title=\"varfoo\">", .exp = 1 },
|
||||
.{ .q = "[ title ~= foo ]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
|
||||
.{ .q = "p[title~=\"FOO\" i]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
|
||||
.{ .q = "p[title~=toofoo i]", .html = "<p title=\"tot foo bar\">", .exp = 0 },
|
||||
.{ .q = "[title~=\"hello world\"]", .html = "<p title=\"hello world\">", .exp = 0 },
|
||||
.{ .q = "[title~=\"hello\" i]", .html = "<p title=\"HELLO world\">", .exp = 1 },
|
||||
.{ .q = "[title~=\"hello\" I]", .html = "<p title=\"HELLO world\">", .exp = 1 },
|
||||
.{ .q = "[lang|=\"en\"]", .html = "<p lang=\"en\"><p lang=\"en-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
|
||||
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
|
||||
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 1 },
|
||||
.{ .q = "[title^=\"foo\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title^=\"foo\" i]", .html = "<p title=\"FooBAR\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title$=\"bar\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title$=\"BAR\" i]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"bar\"]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"BaRu\" i]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"BaRu\" I]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "p[class$=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class$=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class^=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class^=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class*=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class*=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "input[name=Sex][value=F]", .html = "<input type=\"radio\" name=\"Sex\" value=\"F\"/>", .exp = 1 },
|
||||
.{ .q = "table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]", .html = "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout: fixed; width: 100%; border: 0 dashed; border-color: #FFFFFF\"><tr style=\"height:64px\">aaa</tr></table>", .exp = 1 },
|
||||
.{ .q = ".t1:not(.t2)", .html = "<p class=\"t1 t2\">", .exp = 0 },
|
||||
.{ .q = "div:not(.t1)", .html = "<div class=\"t3\">", .exp = 1 },
|
||||
.{ .q = "div:not([class=\"t2\"])", .html = "<div><div class=\"t2\"><div class=\"t3\">", .exp = 1 },
|
||||
.{ .q = "li:nth-child(odd)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-child(even)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-last-child(odd)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-last-child(even)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-last-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-last-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 1 },
|
||||
.{ .q = "span:first-child", .html = "<p>some text <span id=\"1\">and a span</span><span id=\"2\"> and another</span></p>", .exp = 1 },
|
||||
.{ .q = "span:last-child", .html = "<span>a span</span> and some text", .exp = 1 },
|
||||
.{ .q = "p:nth-of-type(2)", .html = "<address></address><p id=1><p id=2>", .exp = 1 },
|
||||
.{ .q = "p:nth-last-of-type(2)", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:last-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:first-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:only-child", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p></div>", .exp = 1 },
|
||||
.{ .q = "p:only-of-type", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p><p id=\"3\"></p></div>", .exp = 1 },
|
||||
.{ .q = ":empty", .html = "<p id=\"1\"><!-- --><p id=\"2\">Hello<p id=\"3\"><span>", .exp = 1 },
|
||||
.{ .q = "div p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "div table p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "div > p", .html = "<div><p id=\"1\"><div><p id=\"2\"></div><table><tr><td><p id=\"3\"></table></div>", .exp = 1 },
|
||||
.{ .q = "p ~ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "p + p", .html = "<p id=\"1\"></p> <!--comment--> <p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "li, p", .html = "<ul><li></li><li></li></ul><p>", .exp = 1 },
|
||||
.{ .q = "p +/*This is a comment*/ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
|
||||
// .{ .q = "p:contains(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
// .{ .q = "p:containsOwn(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 0 },
|
||||
// .{ .q = ":containsOwn(\"inner\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
// .{ .q = "p:containsOwn(\"block\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
// .{ .q = "div:has(#p1)", .html = "<div id=\"d1\"><p id=\"p1\"><span>text content</span></p></div><div id=\"d2\"/>", .exp = 1 },
|
||||
// .{ .q = "div:has(:containsOwn(\"2\"))", .html = "<div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p>contents <em>2</em></p></div>", .exp = 1 },
|
||||
// .{ .q = "body :has(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
|
||||
// .{ .q = "body :haschild(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
|
||||
// .{ .q = "p:matches([\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches([a-z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches([a-zA-Z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches([^\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches(^(0|a))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches(^\\d+$)", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:not(:matches(^\\d+$))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "div :matchesOwn(^\\d+$)", .html = "<div><p id=\"p1\">01234<em>567</em>89</p><div>", .exp = 1 },
|
||||
// .{ .q = "[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"></a> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"></a> <li><a id=\"a2\" href=\"http://finance.untrusted.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"/> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
|
||||
// .{ .q = "[href#=(^https:\\/\\/[^\\/]*\\/?news)]", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"/> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"></a> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
|
||||
.{ .q = ":input", .html = "<form> <label>Username <input type=\"text\" name=\"username\" /></label> <label>Password <input type=\"password\" name=\"password\" /></label> <label>Country <select name=\"country\"> <option value=\"ca\">Canada</option> <option value=\"us\">United States</option> </select> </label> <label>Bio <textarea name=\"bio\"></textarea></label> <button>Sign up</button> </form>", .exp = 1 },
|
||||
.{ .q = ":root", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "html:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root:first-child", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "a:not(:root)", .html = "<html><head></head><body><a href=\"http://www.foo.com\"></a></body></html>", .exp = 1 },
|
||||
.{ .q = "body > *:nth-child(3n+2)", .html = "<html><head></head><body><p></p><div></div><span></span><a></a><form></form></body></html>", .exp = 1 },
|
||||
.{ .q = "input:disabled", .html = "<html><head></head><body><fieldset disabled><legend id=\"1\"><input id=\"i1\"/></legend><legend id=\"2\"><input id=\"i2\"/></legend></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = ":disabled", .html = "<html><head></head><body><fieldset disabled></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = ":enabled", .html = "<html><head></head><body><fieldset></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = "div.class1, div.class2", .html = "<div class=class1></div><div class=class2></div><div class=class3></div>", .exp = 1 },
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const doc = try parser.documentHTMLParseFromStr(tc.html, &Elements.createElement);
|
||||
defer parser.documentHTMLClose(doc) catch {};
|
||||
|
||||
const s = css.parse(alloc, tc.q, .{}) catch |e| {
|
||||
std.debug.print("parse, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
|
||||
defer s.deinit(alloc);
|
||||
|
||||
const node = Node{ .node = parser.documentHTMLToNode(doc) };
|
||||
|
||||
_ = css.matchFirst(s, node, &matcher) catch |e| {
|
||||
std.debug.print("match, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("expectation, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "matchAll" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
html: []const u8,
|
||||
exp: usize,
|
||||
}{
|
||||
.{ .q = "address", .html = "<body><address>This address...</address></body>", .exp = 1 },
|
||||
.{ .q = "*", .html = "<!-- comment --><html><head></head><body>text</body></html>", .exp = 3 },
|
||||
.{ .q = "*", .html = "<html><head></head><body></body></html>", .exp = 3 },
|
||||
.{ .q = "#foo", .html = "<p id=\"foo\"><p id=\"bar\">", .exp = 1 },
|
||||
.{ .q = "li#t1", .html = "<ul><li id=\"t1\"><p id=\"t1\">", .exp = 1 },
|
||||
.{ .q = ".t3", .html = "<ul><li class=\"t1\"><li class=\"t2 t3\">", .exp = 1 },
|
||||
.{ .q = "*#t4", .html = "<ol><li id=\"t4\"><li id=\"t44\">", .exp = 1 },
|
||||
.{ .q = ".t1", .html = "<ul><li class=\"t1\"><li class=\"t2\">", .exp = 1 },
|
||||
.{ .q = "p.t1", .html = "<p class=\"t1 t2\">", .exp = 1 },
|
||||
.{ .q = "div.teST", .html = "<div class=\"test\">", .exp = 0 },
|
||||
.{ .q = ".t1.fail", .html = "<p class=\"t1 t2\">", .exp = 0 },
|
||||
.{ .q = "p.t1.t2", .html = "<p class=\"t1 t2\">", .exp = 1 },
|
||||
.{ .q = "p.--t1", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
|
||||
.{ .q = "p.--t1.--t2", .html = "<p class=\"--t1 --t2\">", .exp = 1 },
|
||||
.{ .q = "p[title]", .html = "<p><p title=\"title\">", .exp = 1 },
|
||||
.{ .q = "div[class=\"red\" i]", .html = "<div><div class=\"Red\">", .exp = 1 },
|
||||
.{ .q = "address[title=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "address[title=\"FoOIgnoRECaSe\" i]", .html = "<address><address title=\"fooIgnoreCase\"><address title=\"bar\">", .exp = 1 },
|
||||
.{ .q = "address[title!=\"foo\"]", .html = "<address><address title=\"foo\"><address title=\"bar\">", .exp = 2 },
|
||||
.{ .q = "address[title!=\"foo\" i]", .html = "<address><address title=\"FOO\"><address title=\"bar\">", .exp = 2 },
|
||||
.{ .q = "p[title!=\"FooBarUFoo\" i]", .html = "<p title=\"fooBARuFOO\"><p title=\"varfoo\">", .exp = 1 },
|
||||
.{ .q = "[ title ~= foo ]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
|
||||
.{ .q = "p[title~=\"FOO\" i]", .html = "<p title=\"tot foo bar\">", .exp = 1 },
|
||||
.{ .q = "p[title~=toofoo i]", .html = "<p title=\"tot foo bar\">", .exp = 0 },
|
||||
.{ .q = "[title~=\"hello world\"]", .html = "<p title=\"hello world\">", .exp = 0 },
|
||||
.{ .q = "[title~=\"hello\" i]", .html = "<p title=\"HELLO world\">", .exp = 1 },
|
||||
.{ .q = "[title~=\"hello\" I]", .html = "<p title=\"HELLO world\">", .exp = 1 },
|
||||
.{ .q = "[lang|=\"en\"]", .html = "<p lang=\"en\"><p lang=\"en-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
|
||||
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
|
||||
.{ .q = "[lang|=\"EN\" i]", .html = "<p lang=\"en\"><p lang=\"En-gb\"><p lang=\"enough\"><p lang=\"fr-en\">", .exp = 2 },
|
||||
.{ .q = "[title^=\"foo\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title^=\"foo\" i]", .html = "<p title=\"FooBAR\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title$=\"bar\"]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title$=\"BAR\" i]", .html = "<p title=\"foobar\"><p title=\"barfoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"bar\"]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"BaRu\" i]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "[title*=\"BaRu\" I]", .html = "<p title=\"foobarufoo\">", .exp = 1 },
|
||||
.{ .q = "p[class$=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class$=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class^=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class^=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class*=\" \"]", .html = "<p class=\" \">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "p[class*=\"\"]", .html = "<p class=\"\">This text should be green.</p><p>This text should be green.</p>", .exp = 0 },
|
||||
.{ .q = "input[name=Sex][value=F]", .html = "<input type=\"radio\" name=\"Sex\" value=\"F\"/>", .exp = 1 },
|
||||
.{ .q = "table[border=\"0\"][cellpadding=\"0\"][cellspacing=\"0\"]", .html = "<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"table-layout: fixed; width: 100%; border: 0 dashed; border-color: #FFFFFF\"><tr style=\"height:64px\">aaa</tr></table>", .exp = 1 },
|
||||
.{ .q = ".t1:not(.t2)", .html = "<p class=\"t1 t2\">", .exp = 0 },
|
||||
.{ .q = "div:not(.t1)", .html = "<div class=\"t3\">", .exp = 1 },
|
||||
.{ .q = "div:not([class=\"t2\"])", .html = "<div><div class=\"t2\"><div class=\"t3\">", .exp = 2 },
|
||||
.{ .q = "li:nth-child(odd)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 2 },
|
||||
.{ .q = "li:nth-child(even)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 2 },
|
||||
.{ .q = "li:nth-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3></ol>", .exp = 1 },
|
||||
.{ .q = "li:nth-last-child(odd)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
|
||||
.{ .q = "li:nth-last-child(even)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
|
||||
.{ .q = "li:nth-last-child(-n+2)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
|
||||
.{ .q = "li:nth-last-child(3n+1)", .html = "<ol><li id=1><li id=2><li id=3><li id=4></ol>", .exp = 2 },
|
||||
.{ .q = "span:first-child", .html = "<p>some text <span id=\"1\">and a span</span><span id=\"2\"> and another</span></p>", .exp = 1 },
|
||||
.{ .q = "span:last-child", .html = "<span>a span</span> and some text", .exp = 1 },
|
||||
.{ .q = "p:nth-of-type(2)", .html = "<address></address><p id=1><p id=2>", .exp = 1 },
|
||||
.{ .q = "p:nth-last-of-type(2)", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:last-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:first-of-type", .html = "<address></address><p id=1><p id=2></p><a>", .exp = 1 },
|
||||
.{ .q = "p:only-child", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p></div>", .exp = 1 },
|
||||
.{ .q = "p:only-of-type", .html = "<div><p id=\"1\"></p><a></a></div><div><p id=\"2\"></p><p id=\"3\"></p></div>", .exp = 1 },
|
||||
.{ .q = ":empty", .html = "<p id=\"1\"><!-- --><p id=\"2\">Hello<p id=\"3\"><span>", .exp = 3 },
|
||||
.{ .q = "div p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 2 },
|
||||
.{ .q = "div table p", .html = "<div><p id=\"1\"><table><tr><td><p id=\"2\"></table></div><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "div > p", .html = "<div><p id=\"1\"><div><p id=\"2\"></div><table><tr><td><p id=\"3\"></table></div>", .exp = 2 },
|
||||
.{ .q = "p ~ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 2 },
|
||||
.{ .q = "p + p", .html = "<p id=\"1\"></p> <!--comment--> <p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
|
||||
.{ .q = "li, p", .html = "<ul><li></li><li></li></ul><p>", .exp = 3 },
|
||||
.{ .q = "p +/*This is a comment*/ p", .html = "<p id=\"1\"><p id=\"2\"></p><address></address><p id=\"3\">", .exp = 1 },
|
||||
// .{ .q = "p:contains(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
// .{ .q = "p:containsOwn(\"that wraps\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 0 },
|
||||
// .{ .q = ":containsOwn(\"inner\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
// .{ .q = "p:containsOwn(\"block\")", .html = "<p>Text block that <span>wraps inner text</span> and continues</p>", .exp = 1 },
|
||||
.{ .q = "div:has(#p1)", .html = "<div id=\"d1\"><p id=\"p1\"><span>text content</span></p></div><div id=\"d2\"/>", .exp = 1 },
|
||||
// .{ .q = "div:has(:containsOwn(\"2\"))", .html = "<div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p>contents <em>2</em></p></div>", .exp = 1 },
|
||||
// .{ .q = "body :has(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 2 },
|
||||
// .{ .q = "body :haschild(:containsOwn(\"2\"))", .html = "<body><div id=\"d1\"><p id=\"p1\"><span>contents 1</span></p></div> <div id=\"d2\"><p id=\"p2\">contents <em>2</em></p></div></body>", .exp = 1 },
|
||||
// .{ .q = "p:matches([\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
|
||||
// .{ .q = "p:matches([a-z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:matches([a-zA-Z])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
|
||||
// .{ .q = "p:matches([^\\d])", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
|
||||
// .{ .q = "p:matches(^(0|a))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 3 },
|
||||
// .{ .q = "p:matches(^\\d+$)", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 1 },
|
||||
// .{ .q = "p:not(:matches(^\\d+$))", .html = "<p id=\"p1\">0123456789</p><p id=\"p2\">abcdef</p><p id=\"p3\">0123ABCD</p>", .exp = 2 },
|
||||
// .{ .q = "div :matchesOwn(^\\d+$)", .html = "<div><p id=\"p1\">01234<em>567</em>89</p><div>", .exp = 2 },
|
||||
// .{ .q = "[href#=(fina)]:not([href#=(\\/\\/[^\\/]+untrusted)])", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"></a> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"></a> <li><a id=\"a2\" href=\"http://finance.untrusted.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"/> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 2 },
|
||||
// .{ .q = "[href#=(^https:\\/\\/[^\\/]*\\/?news)]", .html = "<ul> <li><a id=\"a1\" href=\"http://www.google.com/finance\"/> <li><a id=\"a2\" href=\"http://finance.yahoo.com/\"/> <li><a id=\"a3\" href=\"https://www.google.com/news\"></a> <li><a id=\"a4\" href=\"http://news.yahoo.com\"/> </ul>", .exp = 1 },
|
||||
.{ .q = ":input", .html = "<form> <label>Username <input type=\"text\" name=\"username\" /></label> <label>Password <input type=\"password\" name=\"password\" /></label> <label>Country <select name=\"country\"> <option value=\"ca\">Canada</option> <option value=\"us\">United States</option> </select> </label> <label>Bio <textarea name=\"bio\"></textarea></label> <button>Sign up</button> </form>", .exp = 5 },
|
||||
.{ .q = ":root", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "html:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root:first-child", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "*:root:nth-child(1)", .html = "<html><head></head><body></body></html>", .exp = 1 },
|
||||
.{ .q = "a:not(:root)", .html = "<html><head></head><body><a href=\"http://www.foo.com\"></a></body></html>", .exp = 1 },
|
||||
.{ .q = "body > *:nth-child(3n+2)", .html = "<html><head></head><body><p></p><div></div><span></span><a></a><form></form></body></html>", .exp = 2 },
|
||||
.{ .q = "input:disabled", .html = "<html><head></head><body><fieldset disabled><legend id=\"1\"><input id=\"i1\"/></legend><legend id=\"2\"><input id=\"i2\"/></legend></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = ":disabled", .html = "<html><head></head><body><fieldset disabled></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = ":enabled", .html = "<html><head></head><body><fieldset></fieldset></body></html>", .exp = 1 },
|
||||
.{ .q = "div.class1, div.class2", .html = "<div class=class1></div><div class=class2></div><div class=class3></div>", .exp = 2 },
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const doc = try parser.documentHTMLParseFromStr(tc.html, &Elements.createElement);
|
||||
defer parser.documentHTMLClose(doc) catch {};
|
||||
|
||||
const s = css.parse(alloc, tc.q, .{}) catch |e| {
|
||||
std.debug.print("parse, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
defer s.deinit(alloc);
|
||||
|
||||
const node = Node{ .node = parser.documentHTMLToNode(doc) };
|
||||
|
||||
_ = css.matchAll(s, node, &matcher) catch |e| {
|
||||
std.debug.print("match, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("expectation, query: {s}\n", .{tc.q});
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
587
src/browser/css/match_test.zig
Normal file
587
src/browser/css/match_test.zig
Normal file
@@ -0,0 +1,587 @@
|
||||
// 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 css = @import("css.zig");
|
||||
|
||||
// Node mock implementation for test only.
|
||||
pub const Node = struct {
|
||||
child: ?*const Node = null,
|
||||
last: ?*const Node = null,
|
||||
sibling: ?*const Node = null,
|
||||
prev: ?*const Node = null,
|
||||
par: ?*const Node = null,
|
||||
|
||||
name: []const u8 = "",
|
||||
att: ?[]const u8 = null,
|
||||
|
||||
pub fn firstChild(n: *const Node) !?*const Node {
|
||||
return n.child;
|
||||
}
|
||||
|
||||
pub fn lastChild(n: *const Node) !?*const Node {
|
||||
return n.last;
|
||||
}
|
||||
|
||||
pub fn nextSibling(n: *const Node) !?*const Node {
|
||||
return n.sibling;
|
||||
}
|
||||
|
||||
pub fn prevSibling(n: *const Node) !?*const Node {
|
||||
return n.prev;
|
||||
}
|
||||
|
||||
pub fn parent(n: *const Node) !?*const Node {
|
||||
return n.par;
|
||||
}
|
||||
|
||||
pub fn isElement(_: *const Node) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn isDocument(_: *const Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isComment(_: *const Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isText(_: *const Node) bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isEmptyText(_: *const Node) !bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn tag(n: *const Node) ![]const u8 {
|
||||
return n.name;
|
||||
}
|
||||
|
||||
pub fn attr(n: *const Node, _: []const u8) !?[]const u8 {
|
||||
return n.att;
|
||||
}
|
||||
|
||||
pub fn eql(a: *const Node, b: *const Node) bool {
|
||||
return a == b;
|
||||
}
|
||||
};
|
||||
|
||||
const Matcher = struct {
|
||||
const Nodes = std.ArrayList(*const Node);
|
||||
|
||||
nodes: Nodes,
|
||||
|
||||
fn init(alloc: std.mem.Allocator) Matcher {
|
||||
return .{ .nodes = Nodes.init(alloc) };
|
||||
}
|
||||
|
||||
fn deinit(m: *Matcher) void {
|
||||
m.nodes.deinit();
|
||||
}
|
||||
|
||||
fn reset(m: *Matcher) void {
|
||||
m.nodes.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
pub fn match(m: *Matcher, n: *const Node) !void {
|
||||
try m.nodes.append(n);
|
||||
}
|
||||
};
|
||||
|
||||
test "matchFirst" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
n: Node,
|
||||
exp: usize,
|
||||
}{
|
||||
.{
|
||||
.q = "address",
|
||||
.n = .{ .child = &.{ .name = "body", .child = &.{ .name = "address" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "#foo",
|
||||
.n = .{ .child = &.{ .name = "p", .att = "foo", .child = &.{ .name = "p" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ".t1",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "t1" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ".t1",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "foo t1" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo!=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo!=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo~=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "baz bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo~=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo^=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo$=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo*=rb]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar-baz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "ba" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "strong, a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .name = "p" } }, .sibling = &.{ .name = "a" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "span", .child = &.{
|
||||
.name = "a",
|
||||
.par = &.{ .name = "span", .par = &.{ .name = "p" } },
|
||||
} } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ":not(p)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p:has(a)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p:has(strong)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "p:haschild(a)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p:haschild(strong)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "p:lang(en)",
|
||||
.n = .{ .child = &.{ .name = "p", .att = "en-US", .child = &.{ .name = "a" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "a:lang(en)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .att = "en-US" } } } },
|
||||
.exp = 1,
|
||||
},
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const s = try css.parse(alloc, tc.q, .{});
|
||||
defer s.deinit(alloc);
|
||||
|
||||
_ = css.matchFirst(s, &tc.n, &matcher) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "matchAll" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
n: Node,
|
||||
exp: usize,
|
||||
}{
|
||||
.{
|
||||
.q = "address",
|
||||
.n = .{ .child = &.{ .name = "body", .child = &.{ .name = "address" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "#foo",
|
||||
.n = .{ .child = &.{ .name = "p", .att = "foo", .child = &.{ .name = "p" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ".t1",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "t1" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ".t1",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "foo t1" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo!=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo!=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 2,
|
||||
},
|
||||
.{
|
||||
.q = "[foo~=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "baz bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo~=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "[foo^=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo$=baz]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo*=rb]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "barbaz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "bar-baz" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "[foo|=bar]",
|
||||
.n = .{ .child = &.{ .name = "p", .sibling = &.{ .name = "p", .att = "ba" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "strong, a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 2,
|
||||
},
|
||||
.{
|
||||
.q = "p a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .name = "p" } }, .sibling = &.{ .name = "a" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p a",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "span", .child = &.{
|
||||
.name = "a",
|
||||
.par = &.{ .name = "span", .par = &.{ .name = "p" } },
|
||||
} } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = ":not(p)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 2,
|
||||
},
|
||||
.{
|
||||
.q = "p:has(a)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p:has(strong)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "p:haschild(a)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "p:haschild(strong)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a" }, .sibling = &.{ .name = "strong" } } },
|
||||
.exp = 0,
|
||||
},
|
||||
.{
|
||||
.q = "p:lang(en)",
|
||||
.n = .{ .child = &.{ .name = "p", .att = "en-US", .child = &.{ .name = "a" } } },
|
||||
.exp = 1,
|
||||
},
|
||||
.{
|
||||
.q = "a:lang(en)",
|
||||
.n = .{ .child = &.{ .name = "p", .child = &.{ .name = "a", .par = &.{ .att = "en-US" } } } },
|
||||
.exp = 1,
|
||||
},
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const s = try css.parse(alloc, tc.q, .{});
|
||||
defer s.deinit(alloc);
|
||||
|
||||
css.matchAll(s, &tc.n, &matcher) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
std.testing.expectEqual(tc.exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "pseudo class" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
var p1: Node = .{ .name = "p" };
|
||||
var p2: Node = .{ .name = "p" };
|
||||
var a1: Node = .{ .name = "a" };
|
||||
|
||||
p1.sibling = &p2;
|
||||
p2.prev = &p1;
|
||||
|
||||
p2.sibling = &a1;
|
||||
a1.prev = &p2;
|
||||
|
||||
var root: Node = .{ .child = &p1, .last = &a1 };
|
||||
p1.par = &root;
|
||||
p2.par = &root;
|
||||
a1.par = &root;
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
n: Node,
|
||||
exp: ?*const Node,
|
||||
}{
|
||||
.{ .q = "p:only-child", .n = root, .exp = null },
|
||||
.{ .q = "a:only-of-type", .n = root, .exp = &a1 },
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const s = try css.parse(alloc, tc.q, .{});
|
||||
defer s.deinit(alloc);
|
||||
|
||||
css.matchAll(s, &tc.n, &matcher) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
if (tc.exp) |exp_n| {
|
||||
const exp: usize = 1;
|
||||
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
std.testing.expectEqual(exp_n, matcher.nodes.items[0]) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const exp: usize = 0;
|
||||
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "nth pseudo class" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
var matcher = Matcher.init(alloc);
|
||||
defer matcher.deinit();
|
||||
|
||||
var p1: Node = .{ .name = "p" };
|
||||
var p2: Node = .{ .name = "p" };
|
||||
|
||||
p1.sibling = &p2;
|
||||
p2.prev = &p1;
|
||||
|
||||
var root: Node = .{ .child = &p1, .last = &p2 };
|
||||
p1.par = &root;
|
||||
p2.par = &root;
|
||||
|
||||
const testcases = [_]struct {
|
||||
q: []const u8,
|
||||
n: Node,
|
||||
exp: ?*const Node,
|
||||
}{
|
||||
.{ .q = "a:nth-of-type(1)", .n = root, .exp = null },
|
||||
.{ .q = "p:nth-of-type(1)", .n = root, .exp = &p1 },
|
||||
.{ .q = "p:nth-of-type(2)", .n = root, .exp = &p2 },
|
||||
.{ .q = "p:nth-of-type(0)", .n = root, .exp = null },
|
||||
.{ .q = "p:nth-of-type(2n)", .n = root, .exp = &p2 },
|
||||
.{ .q = "p:nth-last-child(1)", .n = root, .exp = &p2 },
|
||||
.{ .q = "p:nth-last-child(2)", .n = root, .exp = &p1 },
|
||||
.{ .q = "p:nth-child(1)", .n = root, .exp = &p1 },
|
||||
.{ .q = "p:nth-child(2)", .n = root, .exp = &p2 },
|
||||
.{ .q = "p:nth-child(odd)", .n = root, .exp = &p1 },
|
||||
.{ .q = "p:nth-child(even)", .n = root, .exp = &p2 },
|
||||
.{ .q = "p:nth-child(n+2)", .n = root, .exp = &p2 },
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
matcher.reset();
|
||||
|
||||
const s = try css.parse(alloc, tc.q, .{});
|
||||
defer s.deinit(alloc);
|
||||
|
||||
css.matchAll(s, &tc.n, &matcher) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
if (tc.exp) |exp_n| {
|
||||
const exp: usize = 1;
|
||||
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
std.testing.expectEqual(exp_n, matcher.nodes.items[0]) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const exp: usize = 0;
|
||||
std.testing.expectEqual(exp, matcher.nodes.items.len) catch |e| {
|
||||
std.debug.print("query: {s}, parsed selector: {any}\n", .{ tc.q, s });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
950
src/browser/css/parser.zig
Normal file
950
src/browser/css/parser.zig
Normal file
@@ -0,0 +1,950 @@
|
||||
// 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/>.
|
||||
|
||||
// CSS Selector parser
|
||||
// This file is a rewrite in Zig of Cascadia CSS Selector parser.
|
||||
// see https://github.com/andybalholm/cascadia
|
||||
// see https://github.com/andybalholm/cascadia/blob/master/parser.go
|
||||
const std = @import("std");
|
||||
const ascii = std.ascii;
|
||||
|
||||
const selector = @import("selector.zig");
|
||||
const Selector = selector.Selector;
|
||||
const PseudoClass = selector.PseudoClass;
|
||||
const AttributeOP = selector.AttributeOP;
|
||||
const Combinator = selector.Combinator;
|
||||
|
||||
const REPLACEMENT_CHARACTER = &.{ 239, 191, 189 };
|
||||
|
||||
pub const ParseError = error{
|
||||
ExpectedSelector,
|
||||
ExpectedIdentifier,
|
||||
ExpectedName,
|
||||
ExpectedIDSelector,
|
||||
ExpectedClassSelector,
|
||||
ExpectedAttributeSelector,
|
||||
ExpectedString,
|
||||
ExpectedRegexp,
|
||||
ExpectedPseudoClassSelector,
|
||||
ExpectedParenthesis,
|
||||
ExpectedParenthesisClose,
|
||||
ExpectedNthExpression,
|
||||
ExpectedInteger,
|
||||
InvalidEscape,
|
||||
EscapeLineEndingOutsideString,
|
||||
InvalidUnicode,
|
||||
UnicodeIsNotHandled,
|
||||
WriteError,
|
||||
PseudoElementNotAtSelectorEnd,
|
||||
PseudoElementNotUnique,
|
||||
PseudoElementDisabled,
|
||||
InvalidAttributeOperator,
|
||||
InvalidAttributeSelector,
|
||||
InvalidString,
|
||||
InvalidRegexp,
|
||||
InvalidPseudoClassSelector,
|
||||
EmptyPseudoClassSelector,
|
||||
InvalidPseudoClass,
|
||||
InvalidPseudoElement,
|
||||
UnmatchParenthesis,
|
||||
NotHandled,
|
||||
UnknownPseudoSelector,
|
||||
InvalidNthExpression,
|
||||
} || PseudoClass.Error || Combinator.Error || std.mem.Allocator.Error;
|
||||
|
||||
pub const ParseOptions = struct {
|
||||
accept_pseudo_elts: bool = true,
|
||||
};
|
||||
|
||||
pub const Parser = struct {
|
||||
s: []const u8, // string to parse
|
||||
i: usize = 0, // current position
|
||||
|
||||
opts: ParseOptions,
|
||||
|
||||
pub fn parse(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
return p.parseSelectorGroup(alloc);
|
||||
}
|
||||
|
||||
// skipWhitespace consumes whitespace characters and comments.
|
||||
// It returns true if there was actually anything to skip.
|
||||
fn skipWhitespace(p: *Parser) bool {
|
||||
var i = p.i;
|
||||
while (i < p.s.len) {
|
||||
const c = p.s[i];
|
||||
// Whitespaces.
|
||||
if (ascii.isWhitespace(c)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comments.
|
||||
if (c == '/') {
|
||||
if (std.mem.startsWith(u8, p.s[i..], "/*")) {
|
||||
if (std.mem.indexOf(u8, p.s[i..], "*/")) |end| {
|
||||
i += end + "*/".len;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (i > p.i) {
|
||||
p.i = i;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// parseSimpleSelectorSequence parses a selector sequence that applies to
|
||||
// a single element.
|
||||
fn parseSimpleSelectorSequence(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
if (p.i >= p.s.len) {
|
||||
return ParseError.ExpectedSelector;
|
||||
}
|
||||
|
||||
var buf = std.ArrayList(Selector).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
switch (p.s[p.i]) {
|
||||
'*' => {
|
||||
// It's the universal selector. Just skip over it, since it
|
||||
// doesn't affect the meaning.
|
||||
p.i += 1;
|
||||
|
||||
// other version of universal selector
|
||||
if (p.i + 2 < p.s.len and std.mem.eql(u8, "|*", p.s[p.i .. p.i + 2])) {
|
||||
p.i += 2;
|
||||
}
|
||||
},
|
||||
'#', '.', '[', ':' => {
|
||||
// There's no type selector. Wait to process the other till the
|
||||
// main loop.
|
||||
},
|
||||
else => try buf.append(try p.parseTypeSelector(alloc)),
|
||||
}
|
||||
|
||||
var pseudo_elt: ?PseudoClass = null;
|
||||
|
||||
loop: while (p.i < p.s.len) {
|
||||
var ns: Selector = switch (p.s[p.i]) {
|
||||
'#' => try p.parseIDSelector(alloc),
|
||||
'.' => try p.parseClassSelector(alloc),
|
||||
'[' => try p.parseAttributeSelector(alloc),
|
||||
':' => try p.parsePseudoclassSelector(alloc),
|
||||
else => break :loop,
|
||||
};
|
||||
errdefer ns.deinit(alloc);
|
||||
|
||||
// From https://drafts.csswg.org/selectors-3/#pseudo-elements :
|
||||
// "Only one pseudo-element may appear per selector, and if present
|
||||
// it must appear after the sequence of simple selectors that
|
||||
// represents the subjects of the selector.""
|
||||
switch (ns) {
|
||||
.pseudo_element => |e| {
|
||||
// We found a pseudo-element.
|
||||
// Only one pseudo-element is accepted per selector.
|
||||
if (pseudo_elt != null) return ParseError.PseudoElementNotUnique;
|
||||
if (!p.opts.accept_pseudo_elts) return ParseError.PseudoElementDisabled;
|
||||
|
||||
pseudo_elt = e;
|
||||
ns.deinit(alloc);
|
||||
},
|
||||
else => {
|
||||
if (pseudo_elt != null) return ParseError.PseudoElementNotAtSelectorEnd;
|
||||
try buf.append(ns);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// no need wrap the selectors in compoundSelector
|
||||
if (buf.items.len == 1 and pseudo_elt == null) return buf.items[0];
|
||||
|
||||
return .{ .compound = .{ .selectors = try buf.toOwnedSlice(), .pseudo_elt = pseudo_elt } };
|
||||
}
|
||||
|
||||
// parseTypeSelector parses a type selector (one that matches by tag name).
|
||||
fn parseTypeSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
try p.parseIdentifier(buf.writer());
|
||||
|
||||
return .{ .tag = try buf.toOwnedSlice() };
|
||||
}
|
||||
|
||||
// parseIdentifier parses an identifier.
|
||||
fn parseIdentifier(p: *Parser, w: anytype) ParseError!void {
|
||||
const prefix = '-';
|
||||
var numPrefix: usize = 0;
|
||||
|
||||
while (p.s.len > p.i and p.s[p.i] == prefix) {
|
||||
p.i += 1;
|
||||
numPrefix += 1;
|
||||
}
|
||||
|
||||
if (p.s.len <= p.i) {
|
||||
return ParseError.ExpectedSelector;
|
||||
}
|
||||
|
||||
const c = p.s[p.i];
|
||||
if (!nameStart(c) or c == '\\') {
|
||||
return ParseError.ExpectedSelector;
|
||||
}
|
||||
|
||||
var ii: usize = 0;
|
||||
while (ii < numPrefix) {
|
||||
w.writeByte(prefix) catch return ParseError.WriteError;
|
||||
ii += 1;
|
||||
}
|
||||
try parseName(p, w);
|
||||
}
|
||||
|
||||
// parseName parses a name (which is like an identifier, but doesn't have
|
||||
// extra restrictions on the first character).
|
||||
fn parseName(p: *Parser, w: anytype) ParseError!void {
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
var i = p.i;
|
||||
var ok = false;
|
||||
|
||||
while (i < sel_len) {
|
||||
const c = sel[i];
|
||||
|
||||
if (nameChar(c)) {
|
||||
const start = i;
|
||||
while (i < sel_len and nameChar(sel[i])) i += 1;
|
||||
w.writeAll(sel[start..i]) catch return ParseError.WriteError;
|
||||
ok = true;
|
||||
} else if (c == '\\') {
|
||||
p.i = i;
|
||||
try p.parseEscape(w);
|
||||
i = p.i;
|
||||
ok = true;
|
||||
} else if (c == 0) {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
i += 1;
|
||||
if (i == sel_len) {
|
||||
ok = true;
|
||||
}
|
||||
} else {
|
||||
// default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) return ParseError.ExpectedName;
|
||||
p.i = i;
|
||||
}
|
||||
|
||||
// parseEscape parses a backslash escape.
|
||||
// The returned string is owned by the caller.
|
||||
fn parseEscape(p: *Parser, w: anytype) ParseError!void {
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
if (sel_len < p.i + 2 or sel[p.i] != '\\') {
|
||||
p.i += 1;
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = p.i + 1;
|
||||
const c = sel[start];
|
||||
|
||||
// unicode escape (hex)
|
||||
if (ascii.isHex(c)) {
|
||||
var i: usize = start;
|
||||
while (i < start + 6 and i < sel_len and ascii.isHex(sel[i])) {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
const v = std.fmt.parseUnsigned(u21, sel[start..i], 16) catch {
|
||||
p.i = i;
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
};
|
||||
|
||||
if (sel_len >= i) {
|
||||
if (sel_len > i) {
|
||||
switch (sel[i]) {
|
||||
'\r' => {
|
||||
i += 1;
|
||||
if (sel_len > i and sel[i] == '\n') i += 1;
|
||||
},
|
||||
' ', '\t', '\n', std.ascii.control_code.ff => i += 1,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
p.i = i;
|
||||
if (v == 0) {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
var buf: [4]u8 = undefined;
|
||||
const ln = std.unicode.utf8Encode(v, &buf) catch {
|
||||
w.writeAll(REPLACEMENT_CHARACTER) catch return ParseError.WriteError;
|
||||
return;
|
||||
};
|
||||
w.writeAll(buf[0..ln]) catch return ParseError.WriteError;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the literal character after the backslash.
|
||||
p.i += 2;
|
||||
w.writeByte(sel[start]) catch return ParseError.WriteError;
|
||||
}
|
||||
|
||||
// parseIDSelector parses a selector that matches by id attribute.
|
||||
fn parseIDSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedIDSelector;
|
||||
if (p.s[p.i] != '#') return ParseError.ExpectedIDSelector;
|
||||
|
||||
p.i += 1;
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try p.parseName(buf.writer());
|
||||
return .{ .id = try buf.toOwnedSlice() };
|
||||
}
|
||||
|
||||
// parseClassSelector parses a selector that matches by class attribute.
|
||||
fn parseClassSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedClassSelector;
|
||||
if (p.s[p.i] != '.') return ParseError.ExpectedClassSelector;
|
||||
|
||||
p.i += 1;
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try p.parseIdentifier(buf.writer());
|
||||
return .{ .class = try buf.toOwnedSlice() };
|
||||
}
|
||||
|
||||
// parseAttributeSelector parses a selector that matches by attribute value.
|
||||
fn parseAttributeSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
if (p.s[p.i] != '[') return ParseError.ExpectedAttributeSelector;
|
||||
|
||||
p.i += 1;
|
||||
_ = p.skipWhitespace();
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try p.parseIdentifier(buf.writer());
|
||||
const key = try buf.toOwnedSlice();
|
||||
errdefer alloc.free(key);
|
||||
|
||||
lowerstr(key);
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
if (p.s[p.i] == ']') {
|
||||
p.i += 1;
|
||||
return .{ .attribute = .{ .key = key } };
|
||||
}
|
||||
|
||||
if (p.i + 2 >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
|
||||
const op = try parseAttributeOP(p.s[p.i .. p.i + 2]);
|
||||
p.i += op.len();
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
|
||||
buf.clearRetainingCapacity();
|
||||
var is_val: bool = undefined;
|
||||
if (op == .regexp) {
|
||||
is_val = false;
|
||||
try p.parseRegex(buf.writer());
|
||||
} else {
|
||||
is_val = true;
|
||||
switch (p.s[p.i]) {
|
||||
'\'', '"' => try p.parseString(buf.writer()),
|
||||
else => try p.parseIdentifier(buf.writer()),
|
||||
}
|
||||
}
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
|
||||
// check if the attribute contains an ignore case flag
|
||||
var ci = false;
|
||||
if (p.s[p.i] == 'i' or p.s[p.i] == 'I') {
|
||||
ci = true;
|
||||
p.i += 1;
|
||||
}
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedAttributeSelector;
|
||||
|
||||
if (p.s[p.i] != ']') return ParseError.InvalidAttributeSelector;
|
||||
p.i += 1;
|
||||
|
||||
return .{ .attribute = .{
|
||||
.key = key,
|
||||
.val = if (is_val) try buf.toOwnedSlice() else null,
|
||||
.regexp = if (!is_val) try buf.toOwnedSlice() else null,
|
||||
.op = op,
|
||||
.ci = ci,
|
||||
} };
|
||||
}
|
||||
|
||||
// parseString parses a single- or double-quoted string.
|
||||
fn parseString(p: *Parser, writer: anytype) ParseError!void {
|
||||
const sel = p.s;
|
||||
const sel_len = sel.len;
|
||||
|
||||
var i = p.i;
|
||||
if (sel_len < i + 2) return ParseError.ExpectedString;
|
||||
|
||||
const quote = sel[i];
|
||||
i += 1;
|
||||
|
||||
loop: while (i < sel_len) {
|
||||
switch (sel[i]) {
|
||||
'\\' => {
|
||||
if (sel_len > i + 1) {
|
||||
const c = sel[i + 1];
|
||||
switch (c) {
|
||||
'\r' => {
|
||||
if (sel_len > i + 2 and sel[i + 2] == '\n') {
|
||||
i += 3;
|
||||
continue :loop;
|
||||
}
|
||||
i += 2;
|
||||
continue :loop;
|
||||
},
|
||||
'\n', std.ascii.control_code.ff => {
|
||||
i += 2;
|
||||
continue :loop;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
p.i = i;
|
||||
try p.parseEscape(writer);
|
||||
i = p.i;
|
||||
},
|
||||
'\r', '\n', std.ascii.control_code.ff => return ParseError.InvalidString,
|
||||
else => |c| {
|
||||
if (c == quote) break :loop;
|
||||
const start = i;
|
||||
while (i < sel_len) {
|
||||
const cc = sel[i];
|
||||
if (cc == quote or cc == '\\' or c == '\r' or c == '\n' or c == std.ascii.control_code.ff) break;
|
||||
i += 1;
|
||||
}
|
||||
writer.writeAll(sel[start..i]) catch return ParseError.WriteError;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (i >= sel_len) return ParseError.InvalidString;
|
||||
|
||||
// Consume the final quote.
|
||||
i += 1;
|
||||
p.i = i;
|
||||
}
|
||||
|
||||
// parseRegex parses a regular expression; the end is defined by encountering an
|
||||
// unmatched closing ')' or ']' which is not consumed
|
||||
fn parseRegex(p: *Parser, writer: anytype) ParseError!void {
|
||||
var i = p.i;
|
||||
if (p.s.len < i + 2) return ParseError.ExpectedRegexp;
|
||||
|
||||
// number of open parens or brackets;
|
||||
// when it becomes negative, finished parsing regex
|
||||
var open: isize = 0;
|
||||
|
||||
loop: while (i < p.s.len) {
|
||||
switch (p.s[i]) {
|
||||
'(', '[' => open += 1,
|
||||
')', ']' => {
|
||||
open -= 1;
|
||||
if (open < 0) break :loop;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (i >= p.s.len) return ParseError.InvalidRegexp;
|
||||
writer.writeAll(p.s[p.i..i]) catch return ParseError.WriteError;
|
||||
p.i = i;
|
||||
}
|
||||
|
||||
// parsePseudoclassSelector parses a pseudoclass selector like :not(p) or a pseudo-element
|
||||
// For backwards compatibility, both ':' and '::' prefix are allowed for pseudo-elements.
|
||||
// https://drafts.csswg.org/selectors-3/#pseudo-elements
|
||||
fn parsePseudoclassSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedPseudoClassSelector;
|
||||
if (p.s[p.i] != ':') return ParseError.ExpectedPseudoClassSelector;
|
||||
|
||||
p.i += 1;
|
||||
|
||||
var must_pseudo_elt: bool = false;
|
||||
if (p.i >= p.s.len) return ParseError.EmptyPseudoClassSelector;
|
||||
if (p.s[p.i] == ':') { // we found a pseudo-element
|
||||
must_pseudo_elt = true;
|
||||
p.i += 1;
|
||||
}
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try p.parseIdentifier(buf.writer());
|
||||
|
||||
const pseudo_class = try PseudoClass.parse(buf.items);
|
||||
|
||||
// reset the buffer to reuse it.
|
||||
buf.clearRetainingCapacity();
|
||||
|
||||
if (must_pseudo_elt and !pseudo_class.isPseudoElement()) return ParseError.InvalidPseudoElement;
|
||||
|
||||
switch (pseudo_class) {
|
||||
.not, .has, .haschild => {
|
||||
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
|
||||
|
||||
const sel = try p.parseSelectorGroup(alloc);
|
||||
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
|
||||
|
||||
const s = try alloc.create(Selector);
|
||||
errdefer alloc.destroy(s);
|
||||
s.* = sel;
|
||||
|
||||
return .{ .pseudo_class_relative = .{ .pseudo_class = pseudo_class, .match = s } };
|
||||
},
|
||||
.contains, .containsown => {
|
||||
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
|
||||
if (p.i == p.s.len) return ParseError.UnmatchParenthesis;
|
||||
|
||||
switch (p.s[p.i]) {
|
||||
'\'', '"' => try p.parseString(buf.writer()),
|
||||
else => try p.parseString(buf.writer()),
|
||||
}
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.InvalidPseudoClass;
|
||||
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
|
||||
|
||||
const val = try buf.toOwnedSlice();
|
||||
errdefer alloc.free(val);
|
||||
|
||||
lowerstr(val);
|
||||
|
||||
return .{ .pseudo_class_contains = .{ .own = pseudo_class == .containsown, .val = val } };
|
||||
},
|
||||
.matches, .matchesown => {
|
||||
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
|
||||
|
||||
try p.parseRegex(buf.writer());
|
||||
if (p.i >= p.s.len) return ParseError.InvalidPseudoClassSelector;
|
||||
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
|
||||
|
||||
return .{ .pseudo_class_regexp = .{ .own = pseudo_class == .matchesown, .regexp = try buf.toOwnedSlice() } };
|
||||
},
|
||||
.nth_child, .nth_last_child, .nth_of_type, .nth_last_of_type => {
|
||||
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
|
||||
const nth = try p.parseNth(alloc);
|
||||
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
|
||||
|
||||
const last = pseudo_class == .nth_last_child or pseudo_class == .nth_last_of_type;
|
||||
const of_type = pseudo_class == .nth_of_type or pseudo_class == .nth_last_of_type;
|
||||
return .{ .pseudo_class_nth = .{ .a = nth[0], .b = nth[1], .of_type = of_type, .last = last } };
|
||||
},
|
||||
.first_child => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = false, .last = false } },
|
||||
.last_child => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = false, .last = true } },
|
||||
.first_of_type => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = true, .last = false } },
|
||||
.last_of_type => return .{ .pseudo_class_nth = .{ .a = 0, .b = 1, .of_type = true, .last = true } },
|
||||
.only_child => return .{ .pseudo_class_only_child = false },
|
||||
.only_of_type => return .{ .pseudo_class_only_child = true },
|
||||
.input, .empty, .root, .link => return .{ .pseudo_class = pseudo_class },
|
||||
.enabled, .disabled, .checked => return .{ .pseudo_class = pseudo_class },
|
||||
.lang => {
|
||||
if (!p.consumeParenthesis()) return ParseError.ExpectedParenthesis;
|
||||
if (p.i == p.s.len) return ParseError.UnmatchParenthesis;
|
||||
|
||||
try p.parseIdentifier(buf.writer());
|
||||
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.InvalidPseudoClass;
|
||||
if (!p.consumeClosingParenthesis()) return ParseError.ExpectedParenthesisClose;
|
||||
|
||||
const val = try buf.toOwnedSlice();
|
||||
errdefer alloc.free(val);
|
||||
lowerstr(val);
|
||||
|
||||
return .{ .pseudo_class_lang = val };
|
||||
},
|
||||
.visited, .hover, .active, .focus, .target => {
|
||||
// Not applicable in a static context: never match.
|
||||
return .{ .never_match = pseudo_class };
|
||||
},
|
||||
.after, .backdrop, .before, .cue, .first_letter => return .{ .pseudo_element = pseudo_class },
|
||||
.first_line, .grammar_error, .marker, .placeholder => return .{ .pseudo_element = pseudo_class },
|
||||
.selection, .spelling_error => return .{ .pseudo_element = pseudo_class },
|
||||
}
|
||||
}
|
||||
|
||||
// consumeParenthesis consumes an opening parenthesis and any following
|
||||
// whitespace. It returns true if there was actually a parenthesis to skip.
|
||||
fn consumeParenthesis(p: *Parser) bool {
|
||||
if (p.i < p.s.len and p.s[p.i] == '(') {
|
||||
p.i += 1;
|
||||
_ = p.skipWhitespace();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// parseSelectorGroup parses a group of selectors, separated by commas.
|
||||
fn parseSelectorGroup(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
const s = try p.parseSelector(alloc);
|
||||
|
||||
var buf = std.ArrayList(Selector).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try buf.append(s);
|
||||
|
||||
while (p.i < p.s.len) {
|
||||
if (p.s[p.i] != ',') break;
|
||||
p.i += 1;
|
||||
const ss = try p.parseSelector(alloc);
|
||||
try buf.append(ss);
|
||||
}
|
||||
|
||||
if (buf.items.len == 1) return buf.items[0];
|
||||
|
||||
return .{ .group = try buf.toOwnedSlice() };
|
||||
}
|
||||
|
||||
// parseSelector parses a selector that may include combinators.
|
||||
fn parseSelector(p: *Parser, alloc: std.mem.Allocator) ParseError!Selector {
|
||||
_ = p.skipWhitespace();
|
||||
var s = try p.parseSimpleSelectorSequence(alloc);
|
||||
|
||||
while (true) {
|
||||
var combinator: Combinator = .empty;
|
||||
if (p.skipWhitespace()) {
|
||||
combinator = .descendant;
|
||||
}
|
||||
if (p.i >= p.s.len) {
|
||||
return s;
|
||||
}
|
||||
|
||||
switch (p.s[p.i]) {
|
||||
'+', '>', '~' => {
|
||||
combinator = try Combinator.parse(p.s[p.i]);
|
||||
p.i += 1;
|
||||
_ = p.skipWhitespace();
|
||||
},
|
||||
// These characters can't begin a selector, but they can legally occur after one.
|
||||
',', ')' => {
|
||||
return s;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
|
||||
if (combinator == .empty) {
|
||||
return s;
|
||||
}
|
||||
|
||||
const c = try p.parseSimpleSelectorSequence(alloc);
|
||||
|
||||
const first = try alloc.create(Selector);
|
||||
errdefer alloc.destroy(first);
|
||||
first.* = s;
|
||||
|
||||
const second = try alloc.create(Selector);
|
||||
errdefer alloc.destroy(second);
|
||||
second.* = c;
|
||||
|
||||
s = Selector{ .combined = .{ .first = first, .second = second, .combinator = combinator } };
|
||||
}
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
// consumeClosingParenthesis consumes a closing parenthesis and any preceding
|
||||
// whitespace. It returns true if there was actually a parenthesis to skip.
|
||||
fn consumeClosingParenthesis(p: *Parser) bool {
|
||||
const i = p.i;
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i < p.s.len and p.s[p.i] == ')') {
|
||||
p.i += 1;
|
||||
return true;
|
||||
}
|
||||
p.i = i;
|
||||
return false;
|
||||
}
|
||||
|
||||
// parseInteger parses a decimal integer.
|
||||
fn parseInteger(p: *Parser) ParseError!isize {
|
||||
var i = p.i;
|
||||
const start = i;
|
||||
while (i < p.s.len and '0' <= p.s[i] and p.s[i] <= '9') i += 1;
|
||||
if (i == start) return ParseError.ExpectedInteger;
|
||||
p.i = i;
|
||||
|
||||
return std.fmt.parseUnsigned(isize, p.s[start..i], 10) catch ParseError.ExpectedInteger;
|
||||
}
|
||||
|
||||
fn parseNthReadN(p: *Parser, a: isize) ParseError![2]isize {
|
||||
_ = p.skipWhitespace();
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
|
||||
|
||||
return switch (p.s[p.i]) {
|
||||
'+' => {
|
||||
p.i += 1;
|
||||
_ = p.skipWhitespace();
|
||||
const b = try p.parseInteger();
|
||||
return .{ a, b };
|
||||
},
|
||||
'-' => {
|
||||
p.i += 1;
|
||||
_ = p.skipWhitespace();
|
||||
const b = try p.parseInteger();
|
||||
return .{ a, -b };
|
||||
},
|
||||
else => .{ a, 0 },
|
||||
};
|
||||
}
|
||||
|
||||
fn parseNthReadA(p: *Parser, a: isize) ParseError![2]isize {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
|
||||
return switch (p.s[p.i]) {
|
||||
'n', 'N' => {
|
||||
p.i += 1;
|
||||
return p.parseNthReadN(a);
|
||||
},
|
||||
else => .{ 0, a },
|
||||
};
|
||||
}
|
||||
|
||||
fn parseNthNegativeA(p: *Parser) ParseError![2]isize {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
|
||||
const c = p.s[p.i];
|
||||
if (std.ascii.isDigit(c)) {
|
||||
const a = try p.parseInteger() * -1;
|
||||
return p.parseNthReadA(a);
|
||||
}
|
||||
if (c == 'n' or c == 'N') {
|
||||
p.i += 1;
|
||||
return p.parseNthReadN(-1);
|
||||
}
|
||||
|
||||
return ParseError.InvalidNthExpression;
|
||||
}
|
||||
|
||||
fn parseNthPositiveA(p: *Parser) ParseError![2]isize {
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
|
||||
const c = p.s[p.i];
|
||||
if (std.ascii.isDigit(c)) {
|
||||
const a = try p.parseInteger();
|
||||
return p.parseNthReadA(a);
|
||||
}
|
||||
if (c == 'n' or c == 'N') {
|
||||
p.i += 1;
|
||||
return p.parseNthReadN(1);
|
||||
}
|
||||
|
||||
return ParseError.InvalidNthExpression;
|
||||
}
|
||||
|
||||
// parseNth parses the argument for :nth-child (normally of the form an+b).
|
||||
fn parseNth(p: *Parser, alloc: std.mem.Allocator) ParseError![2]isize {
|
||||
// initial state
|
||||
if (p.i >= p.s.len) return ParseError.ExpectedNthExpression;
|
||||
return switch (p.s[p.i]) {
|
||||
'-' => {
|
||||
p.i += 1;
|
||||
return p.parseNthNegativeA();
|
||||
},
|
||||
'+' => {
|
||||
p.i += 1;
|
||||
return p.parseNthPositiveA();
|
||||
},
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9' => p.parseNthPositiveA(),
|
||||
'n', 'N' => {
|
||||
p.i += 1;
|
||||
return p.parseNthReadN(1);
|
||||
},
|
||||
'o', 'O', 'e', 'E' => {
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
try p.parseName(buf.writer());
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("odd", buf.items)) return .{ 2, 1 };
|
||||
if (std.ascii.eqlIgnoreCase("even", buf.items)) return .{ 2, 0 };
|
||||
|
||||
return ParseError.InvalidNthExpression;
|
||||
},
|
||||
else => ParseError.InvalidNthExpression,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// nameStart returns whether c can be the first character of an identifier
|
||||
// (not counting an initial hyphen, or an escape sequence).
|
||||
fn nameStart(c: u8) bool {
|
||||
return 'a' <= c and c <= 'z' or 'A' <= c and c <= 'Z' or c == '_' or c > 127;
|
||||
}
|
||||
|
||||
// nameChar returns whether c can be a character within an identifier
|
||||
// (not counting an escape sequence).
|
||||
fn nameChar(c: u8) bool {
|
||||
return 'a' <= c and c <= 'z' or 'A' <= c and c <= 'Z' or c == '_' or c > 127 or
|
||||
c == '-' or '0' <= c and c <= '9';
|
||||
}
|
||||
|
||||
fn lowerstr(str: []u8) void {
|
||||
for (str, 0..) |c, i| {
|
||||
str[i] = std.ascii.toLower(c);
|
||||
}
|
||||
}
|
||||
|
||||
// parseAttributeOP parses an AttributeOP from a string of 1 or 2 bytes.
|
||||
fn parseAttributeOP(s: []const u8) ParseError!AttributeOP {
|
||||
if (s.len < 1 or s.len > 2) return ParseError.InvalidAttributeOperator;
|
||||
|
||||
// if the first sign is equal, we don't check anything else.
|
||||
if (s[0] == '=') return .eql;
|
||||
|
||||
if (s.len != 2 or s[1] != '=') return ParseError.InvalidAttributeOperator;
|
||||
|
||||
return switch (s[0]) {
|
||||
'=' => .eql,
|
||||
'!' => .not_eql,
|
||||
'~' => .one_of,
|
||||
'|' => .prefix_hyphen,
|
||||
'^' => .prefix,
|
||||
'$' => .suffix,
|
||||
'*' => .contains,
|
||||
'#' => .regexp,
|
||||
else => ParseError.InvalidAttributeOperator,
|
||||
};
|
||||
}
|
||||
|
||||
test "parser.skipWhitespace" {
|
||||
const testcases = [_]struct {
|
||||
s: []const u8,
|
||||
i: usize,
|
||||
r: bool,
|
||||
}{
|
||||
.{ .s = "", .i = 0, .r = false },
|
||||
.{ .s = "foo", .i = 0, .r = false },
|
||||
.{ .s = " ", .i = 1, .r = true },
|
||||
.{ .s = " foo", .i = 1, .r = true },
|
||||
.{ .s = "/* foo */ bar", .i = 10, .r = true },
|
||||
.{ .s = "/* foo", .i = 0, .r = false },
|
||||
};
|
||||
|
||||
for (testcases) |tc| {
|
||||
var p = Parser{ .s = tc.s, .opts = .{} };
|
||||
const res = p.skipWhitespace();
|
||||
try std.testing.expectEqual(tc.r, res);
|
||||
try std.testing.expectEqual(tc.i, p.i);
|
||||
}
|
||||
}
|
||||
|
||||
test "parser.parseIdentifier" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
const testcases = [_]struct {
|
||||
s: []const u8, // given value
|
||||
exp: []const u8, // expected value
|
||||
err: bool = false,
|
||||
}{
|
||||
.{ .s = "x", .exp = "x" },
|
||||
.{ .s = "96", .exp = "", .err = true },
|
||||
.{ .s = "-x", .exp = "-x" },
|
||||
.{ .s = "r\\e9 sumé", .exp = "résumé" },
|
||||
.{ .s = "r\\0000e9 sumé", .exp = "résumé" },
|
||||
.{ .s = "r\\0000e9sumé", .exp = "résumé" },
|
||||
.{ .s = "a\\\"b", .exp = "a\"b" },
|
||||
};
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
for (testcases) |tc| {
|
||||
buf.clearRetainingCapacity();
|
||||
|
||||
var p = Parser{ .s = tc.s, .opts = .{} };
|
||||
p.parseIdentifier(buf.writer()) catch |e| {
|
||||
// if error was expected, continue.
|
||||
if (tc.err) continue;
|
||||
|
||||
std.debug.print("test case {s}\n", .{tc.s});
|
||||
return e;
|
||||
};
|
||||
std.testing.expectEqualDeep(tc.exp, buf.items) catch |e| {
|
||||
std.debug.print("test case {s} : {s}\n", .{ tc.s, buf.items });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test "parser.parseString" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
const testcases = [_]struct {
|
||||
s: []const u8, // given value
|
||||
exp: []const u8, // expected value
|
||||
err: bool = false,
|
||||
}{
|
||||
.{ .s = "\"x\"", .exp = "x" },
|
||||
.{ .s = "'x'", .exp = "x" },
|
||||
.{ .s = "'x", .exp = "", .err = true },
|
||||
.{ .s = "'x\\\r\nx'", .exp = "xx" },
|
||||
.{ .s = "\"r\\e9 sumé\"", .exp = "résumé" },
|
||||
.{ .s = "\"r\\0000e9 sumé\"", .exp = "résumé" },
|
||||
.{ .s = "\"r\\0000e9sumé\"", .exp = "résumé" },
|
||||
.{ .s = "\"a\\\"b\"", .exp = "a\"b" },
|
||||
.{ .s = "\"\\\n\"", .exp = "" },
|
||||
.{ .s = "\"hello world\"", .exp = "hello world" },
|
||||
};
|
||||
|
||||
var buf = std.ArrayList(u8).init(alloc);
|
||||
defer buf.deinit();
|
||||
|
||||
for (testcases) |tc| {
|
||||
buf.clearRetainingCapacity();
|
||||
|
||||
var p = Parser{ .s = tc.s, .opts = .{} };
|
||||
p.parseString(buf.writer()) catch |e| {
|
||||
// if error was expected, continue.
|
||||
if (tc.err) continue;
|
||||
|
||||
std.debug.print("test case {s}\n", .{tc.s});
|
||||
return e;
|
||||
};
|
||||
std.testing.expectEqualDeep(tc.exp, buf.items) catch |e| {
|
||||
std.debug.print("test case {s} : {s}\n", .{ tc.s, buf.items });
|
||||
return e;
|
||||
};
|
||||
}
|
||||
}
|
||||
767
src/browser/css/selector.zig
Normal file
767
src/browser/css/selector.zig
Normal file
@@ -0,0 +1,767 @@
|
||||
// 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 AttributeOP = enum {
|
||||
eql, // =
|
||||
not_eql, // !=
|
||||
one_of, // ~=
|
||||
prefix_hyphen, // |=
|
||||
prefix, // ^=
|
||||
suffix, // $=
|
||||
contains, // *=
|
||||
regexp, // #=
|
||||
|
||||
pub fn len(op: AttributeOP) u2 {
|
||||
if (op == .eql) return 1;
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Combinator = enum {
|
||||
empty,
|
||||
descendant, // space
|
||||
child, // >
|
||||
next_sibling, // +
|
||||
subsequent_sibling, // ~
|
||||
|
||||
pub const Error = error{
|
||||
InvalidCombinator,
|
||||
};
|
||||
|
||||
pub fn parse(c: u8) Error!Combinator {
|
||||
return switch (c) {
|
||||
' ' => .descendant,
|
||||
'>' => .child,
|
||||
'+' => .next_sibling,
|
||||
'~' => .subsequent_sibling,
|
||||
else => Error.InvalidCombinator,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const PseudoClass = enum {
|
||||
not,
|
||||
has,
|
||||
haschild,
|
||||
contains,
|
||||
containsown,
|
||||
matches,
|
||||
matchesown,
|
||||
nth_child,
|
||||
nth_last_child,
|
||||
nth_of_type,
|
||||
nth_last_of_type,
|
||||
first_child,
|
||||
last_child,
|
||||
first_of_type,
|
||||
last_of_type,
|
||||
only_child,
|
||||
only_of_type,
|
||||
input,
|
||||
empty,
|
||||
root,
|
||||
link,
|
||||
lang,
|
||||
enabled,
|
||||
disabled,
|
||||
checked,
|
||||
visited,
|
||||
hover,
|
||||
active,
|
||||
focus,
|
||||
target,
|
||||
after,
|
||||
backdrop,
|
||||
before,
|
||||
cue,
|
||||
first_letter,
|
||||
first_line,
|
||||
grammar_error,
|
||||
marker,
|
||||
placeholder,
|
||||
selection,
|
||||
spelling_error,
|
||||
|
||||
pub const Error = error{
|
||||
InvalidPseudoClass,
|
||||
};
|
||||
|
||||
pub fn isPseudoElement(pc: PseudoClass) bool {
|
||||
return switch (pc) {
|
||||
.after, .backdrop, .before, .cue, .first_letter => true,
|
||||
.first_line, .grammar_error, .marker, .placeholder => true,
|
||||
.selection, .spelling_error => true,
|
||||
else => false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parse(s: []const u8) Error!PseudoClass {
|
||||
if (std.ascii.eqlIgnoreCase(s, "not")) return .not;
|
||||
if (std.ascii.eqlIgnoreCase(s, "has")) return .has;
|
||||
if (std.ascii.eqlIgnoreCase(s, "haschild")) return .haschild;
|
||||
if (std.ascii.eqlIgnoreCase(s, "contains")) return .contains;
|
||||
if (std.ascii.eqlIgnoreCase(s, "containsown")) return .containsown;
|
||||
if (std.ascii.eqlIgnoreCase(s, "matches")) return .matches;
|
||||
if (std.ascii.eqlIgnoreCase(s, "matchesown")) return .matchesown;
|
||||
if (std.ascii.eqlIgnoreCase(s, "nth-child")) return .nth_child;
|
||||
if (std.ascii.eqlIgnoreCase(s, "nth-last-child")) return .nth_last_child;
|
||||
if (std.ascii.eqlIgnoreCase(s, "nth-of-type")) return .nth_of_type;
|
||||
if (std.ascii.eqlIgnoreCase(s, "nth-last-of-type")) return .nth_last_of_type;
|
||||
if (std.ascii.eqlIgnoreCase(s, "first-child")) return .first_child;
|
||||
if (std.ascii.eqlIgnoreCase(s, "last-child")) return .last_child;
|
||||
if (std.ascii.eqlIgnoreCase(s, "first-of-type")) return .first_of_type;
|
||||
if (std.ascii.eqlIgnoreCase(s, "last-of-type")) return .last_of_type;
|
||||
if (std.ascii.eqlIgnoreCase(s, "only-child")) return .only_child;
|
||||
if (std.ascii.eqlIgnoreCase(s, "only-of-type")) return .only_of_type;
|
||||
if (std.ascii.eqlIgnoreCase(s, "input")) return .input;
|
||||
if (std.ascii.eqlIgnoreCase(s, "empty")) return .empty;
|
||||
if (std.ascii.eqlIgnoreCase(s, "root")) return .root;
|
||||
if (std.ascii.eqlIgnoreCase(s, "link")) return .link;
|
||||
if (std.ascii.eqlIgnoreCase(s, "lang")) return .lang;
|
||||
if (std.ascii.eqlIgnoreCase(s, "enabled")) return .enabled;
|
||||
if (std.ascii.eqlIgnoreCase(s, "disabled")) return .disabled;
|
||||
if (std.ascii.eqlIgnoreCase(s, "checked")) return .checked;
|
||||
if (std.ascii.eqlIgnoreCase(s, "visited")) return .visited;
|
||||
if (std.ascii.eqlIgnoreCase(s, "hover")) return .hover;
|
||||
if (std.ascii.eqlIgnoreCase(s, "active")) return .active;
|
||||
if (std.ascii.eqlIgnoreCase(s, "focus")) return .focus;
|
||||
if (std.ascii.eqlIgnoreCase(s, "target")) return .target;
|
||||
if (std.ascii.eqlIgnoreCase(s, "after")) return .after;
|
||||
if (std.ascii.eqlIgnoreCase(s, "backdrop")) return .backdrop;
|
||||
if (std.ascii.eqlIgnoreCase(s, "before")) return .before;
|
||||
if (std.ascii.eqlIgnoreCase(s, "cue")) return .cue;
|
||||
if (std.ascii.eqlIgnoreCase(s, "first-letter")) return .first_letter;
|
||||
if (std.ascii.eqlIgnoreCase(s, "first-line")) return .first_line;
|
||||
if (std.ascii.eqlIgnoreCase(s, "grammar-error")) return .grammar_error;
|
||||
if (std.ascii.eqlIgnoreCase(s, "marker")) return .marker;
|
||||
if (std.ascii.eqlIgnoreCase(s, "placeholder")) return .placeholder;
|
||||
if (std.ascii.eqlIgnoreCase(s, "selection")) return .selection;
|
||||
if (std.ascii.eqlIgnoreCase(s, "spelling-error")) return .spelling_error;
|
||||
return Error.InvalidPseudoClass;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Selector = union(enum) {
|
||||
pub const Error = error{
|
||||
UnknownCombinedCombinator,
|
||||
UnsupportedRelativePseudoClass,
|
||||
UnsupportedContainsPseudoClass,
|
||||
UnsupportedPseudoClass,
|
||||
UnsupportedPseudoElement,
|
||||
UnsupportedRegexpPseudoClass,
|
||||
UnsupportedAttrRegexpOperator,
|
||||
};
|
||||
|
||||
compound: struct {
|
||||
selectors: []Selector,
|
||||
pseudo_elt: ?PseudoClass,
|
||||
},
|
||||
group: []Selector,
|
||||
tag: []const u8,
|
||||
id: []const u8,
|
||||
class: []const u8,
|
||||
attribute: struct {
|
||||
key: []const u8,
|
||||
val: ?[]const u8 = null,
|
||||
op: ?AttributeOP = null,
|
||||
regexp: ?[]const u8 = null,
|
||||
ci: bool = false,
|
||||
},
|
||||
combined: struct {
|
||||
first: *Selector,
|
||||
second: *Selector,
|
||||
combinator: Combinator,
|
||||
},
|
||||
|
||||
never_match: PseudoClass,
|
||||
|
||||
pseudo_class: PseudoClass,
|
||||
pseudo_class_only_child: bool,
|
||||
pseudo_class_lang: []const u8,
|
||||
pseudo_class_relative: struct {
|
||||
pseudo_class: PseudoClass,
|
||||
match: *Selector,
|
||||
},
|
||||
pseudo_class_contains: struct {
|
||||
own: bool,
|
||||
val: []const u8,
|
||||
},
|
||||
pseudo_class_regexp: struct {
|
||||
own: bool,
|
||||
regexp: []const u8,
|
||||
},
|
||||
pseudo_class_nth: struct {
|
||||
a: isize,
|
||||
b: isize,
|
||||
of_type: bool,
|
||||
last: bool,
|
||||
},
|
||||
pseudo_element: PseudoClass,
|
||||
|
||||
// returns true if s is a whitespace-separated list that includes val.
|
||||
fn word(haystack: []const u8, needle: []const u8, ci: bool) bool {
|
||||
if (haystack.len == 0) return false;
|
||||
var it = std.mem.splitAny(u8, haystack, " \t\r\n"); // TODO add \f
|
||||
while (it.next()) |part| {
|
||||
if (eql(part, needle, ci)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn eql(a: []const u8, b: []const u8, ci: bool) bool {
|
||||
if (ci) return std.ascii.eqlIgnoreCase(a, b);
|
||||
return std.mem.eql(u8, a, b);
|
||||
}
|
||||
|
||||
fn starts(haystack: []const u8, needle: []const u8, ci: bool) bool {
|
||||
if (ci) return std.ascii.startsWithIgnoreCase(haystack, needle);
|
||||
return std.mem.startsWith(u8, haystack, needle);
|
||||
}
|
||||
|
||||
fn ends(haystack: []const u8, needle: []const u8, ci: bool) bool {
|
||||
if (ci) return std.ascii.endsWithIgnoreCase(haystack, needle);
|
||||
return std.mem.endsWith(u8, haystack, needle);
|
||||
}
|
||||
|
||||
fn contains(haystack: []const u8, needle: []const u8, ci: bool) bool {
|
||||
if (ci) return std.ascii.indexOfIgnoreCase(haystack, needle) != null;
|
||||
return std.mem.indexOf(u8, haystack, needle) != null;
|
||||
}
|
||||
|
||||
// match returns true if the node matches the selector query.
|
||||
pub fn match(s: Selector, n: anytype) !bool {
|
||||
return switch (s) {
|
||||
.tag => |v| n.isElement() and std.ascii.eqlIgnoreCase(v, try n.tag()),
|
||||
.id => |v| return n.isElement() and std.mem.eql(u8, v, try n.attr("id") orelse return false),
|
||||
.class => |v| return n.isElement() and word(try n.attr("class") orelse return false, v, false),
|
||||
.group => |v| {
|
||||
for (v) |sel| {
|
||||
if (try sel.match(n)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
.compound => |v| {
|
||||
if (v.selectors.len == 0) return n.isElement();
|
||||
|
||||
for (v.selectors) |sel| {
|
||||
if (!try sel.match(n)) return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
.combined => |v| {
|
||||
return switch (v.combinator) {
|
||||
.empty => try v.first.match(n),
|
||||
.descendant => {
|
||||
if (!try v.second.match(n)) return false;
|
||||
|
||||
// The first must match a ascendent.
|
||||
var p = try n.parent();
|
||||
while (p != null) {
|
||||
if (try v.first.match(p.?)) {
|
||||
return true;
|
||||
}
|
||||
p = try p.?.parent();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
.child => {
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
return try v.second.match(n) and try v.first.match(p.?);
|
||||
},
|
||||
.next_sibling => {
|
||||
if (!try v.second.match(n)) return false;
|
||||
var c = try n.prevSibling();
|
||||
while (c != null) {
|
||||
if (c.?.isText() or c.?.isComment()) {
|
||||
c = try c.?.prevSibling();
|
||||
continue;
|
||||
}
|
||||
return try v.first.match(c.?);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
.subsequent_sibling => {
|
||||
if (!try v.second.match(n)) return false;
|
||||
|
||||
var c = try n.prevSibling();
|
||||
while (c != null) {
|
||||
if (try v.first.match(c.?)) return true;
|
||||
c = try c.?.prevSibling();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
.attribute => |v| {
|
||||
var attr = try n.attr(v.key);
|
||||
|
||||
if (v.op == null) return attr != null;
|
||||
if (v.val == null or v.val.?.len == 0) return false;
|
||||
|
||||
const val = v.val.?;
|
||||
|
||||
return switch (v.op.?) {
|
||||
.eql => attr != null and eql(attr.?, val, v.ci),
|
||||
.not_eql => attr == null or !eql(attr.?, val, v.ci),
|
||||
.one_of => attr != null and word(attr.?, val, v.ci),
|
||||
.prefix => {
|
||||
if (attr == null) return false;
|
||||
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
|
||||
|
||||
if (attr.?.len == 0) return false;
|
||||
|
||||
return starts(attr.?, val, v.ci);
|
||||
},
|
||||
.suffix => {
|
||||
if (attr == null) return false;
|
||||
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
|
||||
|
||||
if (attr.?.len == 0) return false;
|
||||
|
||||
return ends(attr.?, val, v.ci);
|
||||
},
|
||||
.contains => {
|
||||
if (attr == null) return false;
|
||||
attr.? = std.mem.trim(u8, attr.?, &std.ascii.whitespace);
|
||||
|
||||
if (attr.?.len == 0) return false;
|
||||
|
||||
return contains(attr.?, val, v.ci);
|
||||
},
|
||||
.prefix_hyphen => {
|
||||
if (attr == null) return false;
|
||||
if (eql(attr.?, val, v.ci)) return true;
|
||||
|
||||
if (attr.?.len <= val.len) return false;
|
||||
|
||||
if (!starts(attr.?, val, v.ci)) return false;
|
||||
|
||||
return attr.?[val.len] == '-';
|
||||
},
|
||||
.regexp => return Error.UnsupportedAttrRegexpOperator, // TODO handle regexp attribute operator.
|
||||
};
|
||||
},
|
||||
.never_match => return false,
|
||||
.pseudo_class_relative => |v| {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
return switch (v.pseudo_class) {
|
||||
.not => !try v.match.match(n),
|
||||
.has => try hasDescendantMatch(v.match, n),
|
||||
.haschild => try hasChildMatch(v.match, n),
|
||||
else => Error.UnsupportedRelativePseudoClass,
|
||||
};
|
||||
},
|
||||
.pseudo_class_contains => return Error.UnsupportedContainsPseudoClass, // TODO, need mem allocation.
|
||||
.pseudo_class_regexp => return Error.UnsupportedRegexpPseudoClass, // TODO need mem allocation.
|
||||
.pseudo_class_nth => |v| {
|
||||
if (v.a == 0) {
|
||||
if (v.last) {
|
||||
return simpleNthLastChildMatch(v.b, v.of_type, n);
|
||||
}
|
||||
return simpleNthChildMatch(v.b, v.of_type, n);
|
||||
}
|
||||
return nthChildMatch(v.a, v.b, v.last, v.of_type, n);
|
||||
},
|
||||
.pseudo_class => |v| {
|
||||
return switch (v) {
|
||||
.input => {
|
||||
if (!n.isElement()) return false;
|
||||
const ntag = try n.tag();
|
||||
|
||||
return std.ascii.eqlIgnoreCase("input", ntag) or
|
||||
std.ascii.eqlIgnoreCase("select", ntag) or
|
||||
std.ascii.eqlIgnoreCase("button", ntag) or
|
||||
std.ascii.eqlIgnoreCase("textarea", ntag);
|
||||
},
|
||||
.empty => {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
var c = try n.firstChild();
|
||||
while (c != null) {
|
||||
if (c.?.isElement()) return false;
|
||||
|
||||
if (c.?.isText()) {
|
||||
if (try c.?.isEmptyText()) continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
.root => {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const p = try n.parent();
|
||||
return (p != null and p.?.isDocument());
|
||||
},
|
||||
.link => {
|
||||
const ntag = try n.tag();
|
||||
|
||||
return std.ascii.eqlIgnoreCase("a", ntag) or
|
||||
std.ascii.eqlIgnoreCase("area", ntag) or
|
||||
std.ascii.eqlIgnoreCase("link", ntag);
|
||||
},
|
||||
.enabled => {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("a", ntag) or
|
||||
std.ascii.eqlIgnoreCase("area", ntag) or
|
||||
std.ascii.eqlIgnoreCase("link", ntag))
|
||||
{
|
||||
return try n.attr("href") != null;
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("optgroup", ntag) or
|
||||
std.ascii.eqlIgnoreCase("menuitem", ntag) or
|
||||
std.ascii.eqlIgnoreCase("fieldset", ntag))
|
||||
{
|
||||
return try n.attr("disabled") == null;
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("input", ntag) or
|
||||
std.ascii.eqlIgnoreCase("button", ntag) or
|
||||
std.ascii.eqlIgnoreCase("select", ntag) or
|
||||
std.ascii.eqlIgnoreCase("textarea", ntag) or
|
||||
std.ascii.eqlIgnoreCase("option", ntag))
|
||||
{
|
||||
return try n.attr("disabled") == null and
|
||||
!try inDisabledFieldset(n);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
.disabled => {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("optgroup", ntag) or
|
||||
std.ascii.eqlIgnoreCase("menuitem", ntag) or
|
||||
std.ascii.eqlIgnoreCase("fieldset", ntag))
|
||||
{
|
||||
return try n.attr("disabled") != null;
|
||||
}
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("input", ntag) or
|
||||
std.ascii.eqlIgnoreCase("button", ntag) or
|
||||
std.ascii.eqlIgnoreCase("select", ntag) or
|
||||
std.ascii.eqlIgnoreCase("textarea", ntag) or
|
||||
std.ascii.eqlIgnoreCase("option", ntag))
|
||||
{
|
||||
return try n.attr("disabled") != null or
|
||||
try inDisabledFieldset(n);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
.checked => {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("intput", ntag)) {
|
||||
const ntype = try n.attr("type");
|
||||
if (ntype == null) return false;
|
||||
|
||||
if (std.mem.eql(u8, ntype.?, "checkbox") or
|
||||
std.mem.eql(u8, ntype.?, "radio"))
|
||||
{
|
||||
return try n.attr("checked") != null;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
if (std.ascii.eqlIgnoreCase("option", ntag)) {
|
||||
return try n.attr("selected") != null;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
.visited => return false,
|
||||
.hover => return false,
|
||||
.active => return false,
|
||||
.focus => return false,
|
||||
// TODO implement using the url fragment.
|
||||
// see https://developer.mozilla.org/en-US/docs/Web/CSS/:target
|
||||
.target => return false,
|
||||
|
||||
// all others pseudo class are handled by specialized
|
||||
// pseudo_class_X selectors.
|
||||
else => return Error.UnsupportedPseudoClass,
|
||||
};
|
||||
},
|
||||
.pseudo_class_only_child => |v| onlyChildMatch(v, n),
|
||||
.pseudo_class_lang => |v| langMatch(v, n),
|
||||
|
||||
// pseudo elements doesn't make sense in the matching process.
|
||||
// > A CSS pseudo-element is a keyword added to a selector that
|
||||
// > lets you style a specific part of the selected element(s).
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements
|
||||
.pseudo_element => return Error.UnsupportedPseudoElement,
|
||||
};
|
||||
}
|
||||
|
||||
fn hasLegendInPreviousSiblings(n: anytype) anyerror!bool {
|
||||
var c = try n.prevSibling();
|
||||
while (c != null) {
|
||||
const ctag = try c.?.tag();
|
||||
if (std.ascii.eqlIgnoreCase("legend", ctag)) return true;
|
||||
c = try c.?.prevSibling();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn inDisabledFieldset(n: anytype) anyerror!bool {
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
const ptag = try p.?.tag();
|
||||
|
||||
if (std.ascii.eqlIgnoreCase("fieldset", ptag) and
|
||||
try p.?.attr("disabled") != null and
|
||||
(!std.ascii.eqlIgnoreCase("legend", ntag) or try hasLegendInPreviousSiblings(n)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO should we handle legend like cascadia does?
|
||||
// The implemention below looks suspicious, I didn't find a test case
|
||||
// in cascadia and I didn't find the reference about legend in the
|
||||
// specs. For now I do prefer ignoring this part.
|
||||
//
|
||||
// ```
|
||||
// (n.DataAtom != atom.Legend || hasLegendInPreviousSiblings(n)) {
|
||||
// ```
|
||||
// https://github.com/andybalholm/cascadia/blob/master/pseudo_classes.go#L434
|
||||
|
||||
return try inDisabledFieldset(p.?);
|
||||
}
|
||||
|
||||
fn langMatch(lang: []const u8, n: anytype) anyerror!bool {
|
||||
if (try n.attr("lang")) |own| {
|
||||
if (std.mem.eql(u8, own, lang)) return true;
|
||||
|
||||
// check if the lang attr starts with lang+'-'
|
||||
if (std.mem.startsWith(u8, own, lang)) {
|
||||
if (own.len > lang.len and own[lang.len] == '-') return true;
|
||||
}
|
||||
}
|
||||
|
||||
// if the tag doesn't match, try the parent.
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
return langMatch(lang, p.?);
|
||||
}
|
||||
|
||||
// onlyChildMatch implements :only-child
|
||||
// If `ofType` is true, it implements :only-of-type instead.
|
||||
fn onlyChildMatch(of_type: bool, n: anytype) anyerror!bool {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
var count: usize = 0;
|
||||
var c = try p.?.firstChild();
|
||||
// loop hover all n siblings.
|
||||
while (c != null) {
|
||||
// ignore non elements or others tags if of-type is true.
|
||||
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
|
||||
c = try c.?.nextSibling();
|
||||
continue;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
if (count > 1) return false;
|
||||
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
return count == 1;
|
||||
}
|
||||
|
||||
// simpleNthLastChildMatch implements :nth-last-child(b).
|
||||
// If ofType is true, implements :nth-last-of-type instead.
|
||||
fn simpleNthLastChildMatch(b: isize, of_type: bool, n: anytype) anyerror!bool {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
var count: isize = 0;
|
||||
var c = try p.?.lastChild();
|
||||
// loop hover all n siblings.
|
||||
while (c != null) {
|
||||
// ignore non elements or others tags if of-type is true.
|
||||
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
|
||||
c = try c.?.prevSibling();
|
||||
continue;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
|
||||
if (n.eql(c.?)) return count == b;
|
||||
if (count >= b) return false;
|
||||
|
||||
c = try c.?.prevSibling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// simpleNthChildMatch implements :nth-child(b).
|
||||
// If ofType is true, implements :nth-of-type instead.
|
||||
fn simpleNthChildMatch(b: isize, of_type: bool, n: anytype) anyerror!bool {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
var count: isize = 0;
|
||||
var c = try p.?.firstChild();
|
||||
// loop hover all n siblings.
|
||||
while (c != null) {
|
||||
// ignore non elements or others tags if of-type is true.
|
||||
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
|
||||
c = try c.?.nextSibling();
|
||||
continue;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
|
||||
if (n.eql(c.?)) return count == b;
|
||||
if (count >= b) return false;
|
||||
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// nthChildMatch implements :nth-child(an+b).
|
||||
// If last is true, implements :nth-last-child instead.
|
||||
// If ofType is true, implements :nth-of-type instead.
|
||||
fn nthChildMatch(a: isize, b: isize, last: bool, of_type: bool, n: anytype) anyerror!bool {
|
||||
if (!n.isElement()) return false;
|
||||
|
||||
const p = try n.parent();
|
||||
if (p == null) return false;
|
||||
|
||||
const ntag = try n.tag();
|
||||
|
||||
var i: isize = -1;
|
||||
var count: isize = 0;
|
||||
var c = try p.?.firstChild();
|
||||
// loop hover all n siblings.
|
||||
while (c != null) {
|
||||
// ignore non elements or others tags if of-type is true.
|
||||
if (!c.?.isElement() or (of_type and !std.mem.eql(u8, ntag, try c.?.tag()))) {
|
||||
c = try c.?.nextSibling();
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
|
||||
if (n.eql(c.?)) {
|
||||
i = count;
|
||||
if (!last) break;
|
||||
}
|
||||
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
if (i == -1) return false;
|
||||
|
||||
if (last) i = count - i + 1;
|
||||
|
||||
i -= b;
|
||||
if (a == 0) return i == 0;
|
||||
return @mod(i, a) == 0 and @divTrunc(i, a) >= 0;
|
||||
}
|
||||
|
||||
fn hasDescendantMatch(s: *const Selector, n: anytype) anyerror!bool {
|
||||
var c = try n.firstChild();
|
||||
while (c != null) {
|
||||
if (try s.match(c.?)) return true;
|
||||
if (c.?.isElement() and try hasDescendantMatch(s, c.?)) return true;
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hasChildMatch(s: *const Selector, n: anytype) anyerror!bool {
|
||||
var c = try n.firstChild();
|
||||
while (c != null) {
|
||||
if (try s.match(c.?)) return true;
|
||||
c = try c.?.nextSibling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn deinit(sel: Selector, alloc: std.mem.Allocator) void {
|
||||
switch (sel) {
|
||||
.group => |v| {
|
||||
for (v) |vv| vv.deinit(alloc);
|
||||
alloc.free(v);
|
||||
},
|
||||
.compound => |v| {
|
||||
for (v.selectors) |vv| vv.deinit(alloc);
|
||||
alloc.free(v.selectors);
|
||||
},
|
||||
.tag, .id, .class, .pseudo_class_lang => |v| alloc.free(v),
|
||||
.attribute => |att| {
|
||||
alloc.free(att.key);
|
||||
if (att.val) |v| alloc.free(v);
|
||||
if (att.regexp) |v| alloc.free(v);
|
||||
},
|
||||
.combined => |c| {
|
||||
c.first.deinit(alloc);
|
||||
alloc.destroy(c.first);
|
||||
c.second.deinit(alloc);
|
||||
alloc.destroy(c.second);
|
||||
},
|
||||
.pseudo_class_relative => |v| {
|
||||
v.match.deinit(alloc);
|
||||
alloc.destroy(v.match);
|
||||
},
|
||||
.pseudo_class_contains => |v| alloc.free(v.val),
|
||||
.pseudo_class_regexp => |v| alloc.free(v.regexp),
|
||||
.pseudo_class, .pseudo_element, .never_match => {},
|
||||
.pseudo_class_nth, .pseudo_class_only_child => {},
|
||||
}
|
||||
}
|
||||
};
|
||||
291
src/browser/cssom/css_parser.zig
Normal file
291
src/browser/cssom/css_parser.zig
Normal file
@@ -0,0 +1,291 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const CSSConstants = struct {
|
||||
const IMPORTANT = "!important";
|
||||
const URL_PREFIX = "url(";
|
||||
};
|
||||
|
||||
pub const CSSParserState = enum {
|
||||
seek_name,
|
||||
in_name,
|
||||
seek_colon,
|
||||
seek_value,
|
||||
in_value,
|
||||
in_quoted_value,
|
||||
in_single_quoted_value,
|
||||
in_url,
|
||||
in_important,
|
||||
};
|
||||
|
||||
pub const CSSDeclaration = struct {
|
||||
name: []const u8,
|
||||
value: []const u8,
|
||||
is_important: bool,
|
||||
};
|
||||
|
||||
pub const CSSParser = struct {
|
||||
state: CSSParserState,
|
||||
name_start: usize,
|
||||
name_end: usize,
|
||||
value_start: usize,
|
||||
position: usize,
|
||||
paren_depth: usize,
|
||||
escape_next: bool,
|
||||
|
||||
pub fn init() CSSParser {
|
||||
return .{
|
||||
.state = .seek_name,
|
||||
.name_start = 0,
|
||||
.name_end = 0,
|
||||
.value_start = 0,
|
||||
.position = 0,
|
||||
.paren_depth = 0,
|
||||
.escape_next = false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn parseDeclarations(arena: Allocator, text: []const u8) ![]CSSDeclaration {
|
||||
var parser = init();
|
||||
var declarations: std.ArrayListUnmanaged(CSSDeclaration) = .empty;
|
||||
|
||||
while (parser.position < text.len) {
|
||||
const c = text[parser.position];
|
||||
|
||||
switch (parser.state) {
|
||||
.seek_name => {
|
||||
if (!std.ascii.isWhitespace(c)) {
|
||||
parser.name_start = parser.position;
|
||||
parser.state = .in_name;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.in_name => {
|
||||
if (c == ':') {
|
||||
parser.name_end = parser.position;
|
||||
parser.state = .seek_value;
|
||||
} else if (std.ascii.isWhitespace(c)) {
|
||||
parser.name_end = parser.position;
|
||||
parser.state = .seek_colon;
|
||||
}
|
||||
},
|
||||
.seek_colon => {
|
||||
if (c == ':') {
|
||||
parser.state = .seek_value;
|
||||
} else if (!std.ascii.isWhitespace(c)) {
|
||||
parser.state = .seek_name;
|
||||
continue;
|
||||
}
|
||||
},
|
||||
.seek_value => {
|
||||
if (!std.ascii.isWhitespace(c)) {
|
||||
parser.value_start = parser.position;
|
||||
if (c == '"') {
|
||||
parser.state = .in_quoted_value;
|
||||
} else if (c == '\'') {
|
||||
parser.state = .in_single_quoted_value;
|
||||
} else if (c == 'u' and parser.position + CSSConstants.URL_PREFIX.len <= text.len and std.mem.startsWith(u8, text[parser.position..], CSSConstants.URL_PREFIX)) {
|
||||
parser.state = .in_url;
|
||||
parser.paren_depth = 1;
|
||||
parser.position += 3;
|
||||
} else {
|
||||
parser.state = .in_value;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
},
|
||||
.in_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '(') {
|
||||
parser.paren_depth += 1;
|
||||
} else if (c == ')' and parser.paren_depth > 0) {
|
||||
parser.paren_depth -= 1;
|
||||
} else if (c == ';' and parser.paren_depth == 0) {
|
||||
try parser.finishDeclaration(arena, &declarations, text);
|
||||
parser.state = .seek_name;
|
||||
}
|
||||
},
|
||||
.in_quoted_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '"') {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
},
|
||||
.in_single_quoted_value => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '\'') {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
},
|
||||
.in_url => {
|
||||
if (parser.escape_next) {
|
||||
parser.escape_next = false;
|
||||
} else if (c == '\\') {
|
||||
parser.escape_next = true;
|
||||
} else if (c == '(') {
|
||||
parser.paren_depth += 1;
|
||||
} else if (c == ')') {
|
||||
parser.paren_depth -= 1;
|
||||
if (parser.paren_depth == 0) {
|
||||
parser.state = .in_value;
|
||||
}
|
||||
}
|
||||
},
|
||||
.in_important => {},
|
||||
}
|
||||
|
||||
parser.position += 1;
|
||||
}
|
||||
|
||||
try parser.finalize(arena, &declarations, text);
|
||||
|
||||
return declarations.items;
|
||||
}
|
||||
|
||||
fn finishDeclaration(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
|
||||
const name = std.mem.trim(u8, text[self.name_start..self.name_end], &std.ascii.whitespace);
|
||||
if (name.len == 0) return;
|
||||
|
||||
const raw_value = text[self.value_start..self.position];
|
||||
const value = std.mem.trim(u8, raw_value, &std.ascii.whitespace);
|
||||
|
||||
var final_value = value;
|
||||
var is_important = false;
|
||||
|
||||
if (std.mem.endsWith(u8, value, CSSConstants.IMPORTANT)) {
|
||||
is_important = true;
|
||||
final_value = std.mem.trimRight(u8, value[0 .. value.len - CSSConstants.IMPORTANT.len], &std.ascii.whitespace);
|
||||
}
|
||||
|
||||
try declarations.append(arena, .{
|
||||
.name = name,
|
||||
.value = final_value,
|
||||
.is_important = is_important,
|
||||
});
|
||||
}
|
||||
|
||||
fn finalize(self: *CSSParser, arena: Allocator, declarations: *std.ArrayListUnmanaged(CSSDeclaration), text: []const u8) !void {
|
||||
if (self.state != .in_value) {
|
||||
return;
|
||||
}
|
||||
return self.finishDeclaration(arena, declarations, text);
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "CSSParser - Simple property" {
|
||||
defer testing.reset();
|
||||
|
||||
const text = "color: red;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("red", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Property with !important" {
|
||||
defer testing.reset();
|
||||
const text = "margin: 10px !important;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("margin", declarations[0].name);
|
||||
try testing.expectEqual("10px", declarations[0].value);
|
||||
try testing.expectEqual(true, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Multiple properties" {
|
||||
defer testing.reset();
|
||||
const text = "color: red; font-size: 12px; margin: 5px !important;";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expect(declarations.len == 3);
|
||||
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("red", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
|
||||
try testing.expectEqual("font-size", declarations[1].name);
|
||||
try testing.expectEqual("12px", declarations[1].value);
|
||||
try testing.expectEqual(false, declarations[1].is_important);
|
||||
|
||||
try testing.expectEqual("margin", declarations[2].name);
|
||||
try testing.expectEqual("5px", declarations[2].value);
|
||||
try testing.expectEqual(true, declarations[2].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Quoted value with semicolon" {
|
||||
defer testing.reset();
|
||||
const text = "content: \"Hello; world!\";";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("content", declarations[0].name);
|
||||
try testing.expectEqual("\"Hello; world!\"", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - URL value" {
|
||||
defer testing.reset();
|
||||
const text = "background-image: url(\"test.png\");";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(1, declarations.len);
|
||||
try testing.expectEqual("background-image", declarations[0].name);
|
||||
try testing.expectEqual("url(\"test.png\")", declarations[0].value);
|
||||
try testing.expectEqual(false, declarations[0].is_important);
|
||||
}
|
||||
|
||||
test "CSSParser - Whitespace handling" {
|
||||
defer testing.reset();
|
||||
const text = " color : purple ; margin : 10px ; ";
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
const declarations = try CSSParser.parseDeclarations(allocator, text);
|
||||
|
||||
try testing.expectEqual(2, declarations.len);
|
||||
try testing.expectEqual("color", declarations[0].name);
|
||||
try testing.expectEqual("purple", declarations[0].value);
|
||||
try testing.expectEqual("margin", declarations[1].name);
|
||||
try testing.expectEqual("10px", declarations[1].value);
|
||||
}
|
||||
247
src/browser/cssom/css_style_declaration.zig
Normal file
247
src/browser/cssom/css_style_declaration.zig
Normal file
@@ -0,0 +1,247 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
pub const Interfaces = .{
|
||||
CSSStyleDeclaration,
|
||||
CSSRule,
|
||||
};
|
||||
|
||||
const CSSRule = struct {};
|
||||
|
||||
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() ?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" },
|
||||
}, .{});
|
||||
}
|
||||
811
src/browser/cssom/css_value_analyzer.zig
Normal file
811
src/browser/cssom/css_value_analyzer.zig
Normal file
@@ -0,0 +1,811 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const CSSValueAnalyzer = struct {
|
||||
pub fn isNumericWithUnit(value: []const u8) bool {
|
||||
if (value.len == 0) return false;
|
||||
|
||||
if (!std.ascii.isDigit(value[0]) and
|
||||
value[0] != '+' and value[0] != '-' and value[0] != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var i: usize = 0;
|
||||
var has_digit = false;
|
||||
var decimal_point = false;
|
||||
|
||||
while (i < value.len) : (i += 1) {
|
||||
const c = value[i];
|
||||
if (std.ascii.isDigit(c)) {
|
||||
has_digit = true;
|
||||
} else if (c == '.' and !decimal_point) {
|
||||
decimal_point = true;
|
||||
} else if ((c == 'e' or c == 'E') and has_digit) {
|
||||
if (i + 1 >= value.len) return false;
|
||||
if (value[i + 1] != '+' and value[i + 1] != '-' and !std.ascii.isDigit(value[i + 1])) break;
|
||||
i += 1;
|
||||
if (value[i] == '+' or value[i] == '-') {
|
||||
i += 1;
|
||||
}
|
||||
var has_exp_digits = false;
|
||||
while (i < value.len and std.ascii.isDigit(value[i])) : (i += 1) {
|
||||
has_exp_digits = true;
|
||||
}
|
||||
if (!has_exp_digits) return false;
|
||||
break;
|
||||
} else if (c != '-' and c != '+') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!has_digit) return false;
|
||||
|
||||
if (i == value.len) return true;
|
||||
|
||||
const unit = value[i..];
|
||||
return CSSKeywords.isValidUnit(unit);
|
||||
}
|
||||
|
||||
pub fn isHexColor(value: []const u8) bool {
|
||||
if (!std.mem.startsWith(u8, value, "#")) return false;
|
||||
|
||||
const hex_part = value[1..];
|
||||
if (hex_part.len != 3 and hex_part.len != 6 and hex_part.len != 8) return false;
|
||||
|
||||
for (hex_part) |c| {
|
||||
if (!std.ascii.isHex(c)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn isMultiValueProperty(value: []const u8) bool {
|
||||
var parts = std.mem.splitAny(u8, value, " ");
|
||||
var multi_value_parts: usize = 0;
|
||||
var all_parts_valid = true;
|
||||
|
||||
while (parts.next()) |part| {
|
||||
if (part.len == 0) continue;
|
||||
multi_value_parts += 1;
|
||||
|
||||
const is_numeric = isNumericWithUnit(part);
|
||||
const is_hex_color = isHexColor(part);
|
||||
const is_known_keyword = CSSKeywords.isKnownKeyword(part);
|
||||
const is_function = CSSKeywords.startsWithFunction(part);
|
||||
|
||||
if (!is_numeric and !is_hex_color and !is_known_keyword and !is_function) {
|
||||
all_parts_valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return multi_value_parts >= 2 and all_parts_valid;
|
||||
}
|
||||
|
||||
pub fn isAlreadyQuoted(value: []const u8) bool {
|
||||
return value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or
|
||||
(value[0] == '\'' and value[value.len - 1] == '\''));
|
||||
}
|
||||
|
||||
pub fn isValidPropertyName(name: []const u8) bool {
|
||||
if (name.len == 0) return false;
|
||||
|
||||
if (std.mem.startsWith(u8, name, "--")) {
|
||||
if (name.len == 2) return false;
|
||||
for (name[2..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const first_char = name[0];
|
||||
if (!std.ascii.isAlphabetic(first_char) and first_char != '-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (first_char == '-') {
|
||||
if (name.len < 2) return false;
|
||||
|
||||
if (!std.ascii.isAlphabetic(name[1])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (name[2..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (name[1..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn extractImportant(value: []const u8) struct { value: []const u8, is_important: bool } {
|
||||
const trimmed = std.mem.trim(u8, value, &std.ascii.whitespace);
|
||||
|
||||
if (std.mem.endsWith(u8, trimmed, "!important")) {
|
||||
const clean_value = std.mem.trimRight(u8, trimmed[0 .. trimmed.len - 10], &std.ascii.whitespace);
|
||||
return .{ .value = clean_value, .is_important = true };
|
||||
}
|
||||
|
||||
return .{ .value = trimmed, .is_important = false };
|
||||
}
|
||||
|
||||
pub fn needsQuotes(value: []const u8) bool {
|
||||
if (value.len == 0) return true;
|
||||
if (isAlreadyQuoted(value)) return false;
|
||||
|
||||
if (CSSKeywords.containsSpecialChar(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (std.mem.indexOfScalar(u8, value, ' ') == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const is_url = std.mem.startsWith(u8, value, "url(");
|
||||
const is_function = CSSKeywords.startsWithFunction(value);
|
||||
|
||||
return !isMultiValueProperty(value) and
|
||||
!is_url and
|
||||
!is_function;
|
||||
}
|
||||
|
||||
pub fn escapeCSSValue(arena: std.mem.Allocator, value: []const u8) ![]const u8 {
|
||||
if (!needsQuotes(value)) {
|
||||
return value;
|
||||
}
|
||||
var out: std.ArrayListUnmanaged(u8) = .empty;
|
||||
|
||||
// We'll need at least this much space, +2 for the quotes
|
||||
try out.ensureTotalCapacity(arena, value.len + 2);
|
||||
const writer = out.writer(arena);
|
||||
|
||||
try writer.writeByte('"');
|
||||
|
||||
for (value, 0..) |c, i| {
|
||||
switch (c) {
|
||||
'"' => try writer.writeAll("\\\""),
|
||||
'\\' => try writer.writeAll("\\\\"),
|
||||
'\n' => try writer.writeAll("\\A "),
|
||||
'\r' => try writer.writeAll("\\D "),
|
||||
'\t' => try writer.writeAll("\\9 "),
|
||||
0...8, 11, 12, 14...31, 127 => {
|
||||
try writer.print("\\{x}", .{c});
|
||||
if (i + 1 < value.len and std.ascii.isHex(value[i + 1])) {
|
||||
try writer.writeByte(' ');
|
||||
}
|
||||
},
|
||||
else => try writer.writeByte(c),
|
||||
}
|
||||
}
|
||||
|
||||
try writer.writeByte('"');
|
||||
return out.items;
|
||||
}
|
||||
|
||||
pub fn isKnownKeyword(value: []const u8) bool {
|
||||
return CSSKeywords.isKnownKeyword(value);
|
||||
}
|
||||
|
||||
pub fn containsSpecialChar(value: []const u8) bool {
|
||||
return CSSKeywords.containsSpecialChar(value);
|
||||
}
|
||||
};
|
||||
|
||||
const CSSKeywords = struct {
|
||||
const border_styles = [_][]const u8{
|
||||
"none", "solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset",
|
||||
};
|
||||
|
||||
const color_names = [_][]const u8{
|
||||
"black", "white", "red", "green", "blue", "yellow", "purple", "gray", "transparent",
|
||||
"currentColor", "inherit",
|
||||
};
|
||||
|
||||
const position_keywords = [_][]const u8{
|
||||
"auto", "center", "left", "right", "top", "bottom",
|
||||
};
|
||||
|
||||
const background_repeat = [_][]const u8{
|
||||
"repeat", "no-repeat", "repeat-x", "repeat-y", "space", "round",
|
||||
};
|
||||
|
||||
const font_styles = [_][]const u8{
|
||||
"normal", "italic", "oblique", "bold", "bolder", "lighter",
|
||||
};
|
||||
|
||||
const font_sizes = [_][]const u8{
|
||||
"xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large",
|
||||
"smaller", "larger",
|
||||
};
|
||||
|
||||
const font_families = [_][]const u8{
|
||||
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
|
||||
};
|
||||
|
||||
const css_global = [_][]const u8{
|
||||
"initial", "inherit", "unset", "revert",
|
||||
};
|
||||
|
||||
const display_values = [_][]const u8{
|
||||
"block", "inline", "inline-block", "flex", "grid", "none",
|
||||
};
|
||||
|
||||
const length_units = [_][]const u8{
|
||||
"px", "em", "rem", "vw", "vh", "vmin", "vmax", "%", "pt", "pc", "in", "cm", "mm",
|
||||
"ex", "ch", "fr",
|
||||
};
|
||||
|
||||
const angle_units = [_][]const u8{
|
||||
"deg", "rad", "grad", "turn",
|
||||
};
|
||||
|
||||
const time_units = [_][]const u8{
|
||||
"s", "ms",
|
||||
};
|
||||
|
||||
const frequency_units = [_][]const u8{
|
||||
"Hz", "kHz",
|
||||
};
|
||||
|
||||
const resolution_units = [_][]const u8{
|
||||
"dpi", "dpcm", "dppx",
|
||||
};
|
||||
|
||||
const special_chars = [_]u8{
|
||||
'"', '\'', ';', '{', '}', '\\', '<', '>', '/', '\n', '\t', '\r', '\x00', '\x7F',
|
||||
};
|
||||
|
||||
const functions = [_][]const u8{
|
||||
"rgb(", "rgba(", "hsl(", "hsla(", "url(", "calc(", "var(", "attr(",
|
||||
"linear-gradient(", "radial-gradient(", "conic-gradient(", "translate(", "rotate(", "scale(", "skew(", "matrix(",
|
||||
};
|
||||
|
||||
pub fn isKnownKeyword(value: []const u8) bool {
|
||||
const all_categories = [_][]const []const u8{
|
||||
&border_styles, &color_names, &position_keywords, &background_repeat,
|
||||
&font_styles, &font_sizes, &font_families, &css_global,
|
||||
&display_values,
|
||||
};
|
||||
|
||||
for (all_categories) |category| {
|
||||
for (category) |keyword| {
|
||||
if (std.ascii.eqlIgnoreCase(value, keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn containsSpecialChar(value: []const u8) bool {
|
||||
for (value) |c| {
|
||||
for (special_chars) |special| {
|
||||
if (c == special) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn isValidUnit(unit: []const u8) bool {
|
||||
const all_units = [_][]const []const u8{
|
||||
&length_units, &angle_units, &time_units, &frequency_units, &resolution_units,
|
||||
};
|
||||
|
||||
for (all_units) |category| {
|
||||
for (category) |valid_unit| {
|
||||
if (std.ascii.eqlIgnoreCase(unit, valid_unit)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn startsWithFunction(value: []const u8) bool {
|
||||
const pos = std.mem.indexOfScalar(u8, value, '(') orelse return false;
|
||||
if (pos == 0) return false;
|
||||
|
||||
if (std.mem.indexOfScalarPos(u8, value, pos, ')') == null) {
|
||||
return false;
|
||||
}
|
||||
const function_name = value[0..pos];
|
||||
return isValidFunctionName(function_name);
|
||||
}
|
||||
|
||||
fn isValidFunctionName(name: []const u8) bool {
|
||||
if (name.len == 0) return false;
|
||||
|
||||
const first = name[0];
|
||||
if (!std.ascii.isAlphabetic(first) and first != '_' and first != '-') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (name[1..]) |c| {
|
||||
if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - valid numbers with units" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14em"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5rem"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("+12.5%"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0vh"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".5vw"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - scientific notation" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e5px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("2.5E-3em"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e+2rem"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-3.14e10px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - edge cases and invalid inputs" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("--px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(".px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1epx"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1e+px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("1.2.3px"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10xyz"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("5invalid"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("10"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("3.14"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("-5"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - valid hex colors" {
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#000"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#fff"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#123456"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#abcdef"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#ABCDEF"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#12345678"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - invalid hex colors" {
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#00"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#00000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#0000000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#000000000"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#gggggg"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#123xyz"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - valid multi-value properties" {
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("#fff black"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("1em 2em 3em 4em"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) solid"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - invalid multi-value properties" {
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("invalid unknown"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px invalid"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(" "));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isAlreadyQuoted - various quoting scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'world'"));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("''"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello'"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - valid property names" {
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("background-color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("font-size"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("margin-top"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("z-index"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("line-height"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - invalid property names" {
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("123color"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color!"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color space"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("@color"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color.test"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("color_test"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - with and without !important" {
|
||||
var result = CSSValueAnalyzer.extractImportant("red !important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("blue");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("blue", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant(" green !important ");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("green", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("!important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("important", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: needsQuotes - various scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("hello world"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("test;"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("a{b}"));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("test\"quote"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("\"already quoted\""));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("'already quoted'"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(image.png)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("10px 20px"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("simple"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: escapeCSSValue - escaping various characters" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "simple");
|
||||
try testing.expectEqual("simple", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "\"already quoted\"");
|
||||
try testing.expectEqual("\"already quoted\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote");
|
||||
try testing.expectEqual("\"test\\\"quote\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\nline");
|
||||
try testing.expectEqual("\"test\\A line\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\\back");
|
||||
try testing.expectEqual("\"test\\\\back\"", result);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.isKnownKeyword - case sensitivity" {
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("red"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("solid"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("center"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("inherit"));
|
||||
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("RED"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("Red"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("SOLID"));
|
||||
try testing.expect(CSSKeywords.isKnownKeyword("Center"));
|
||||
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword("invalid"));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword("unknown"));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.containsSpecialChar - various special characters" {
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test\"quote"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test'quote"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test;end"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test{brace"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test}brace"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test\\back"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test<angle"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test>angle"));
|
||||
try testing.expect(CSSKeywords.containsSpecialChar("test/slash"));
|
||||
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar("normal-text"));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar("text123"));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.isValidUnit - various units" {
|
||||
try testing.expect(CSSKeywords.isValidUnit("px"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("em"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("rem"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("%"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("deg"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("rad"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("s"));
|
||||
try testing.expect(CSSKeywords.isValidUnit("ms"));
|
||||
|
||||
try testing.expect(CSSKeywords.isValidUnit("PX"));
|
||||
|
||||
try testing.expect(!CSSKeywords.isValidUnit("invalid"));
|
||||
try testing.expect(!CSSKeywords.isValidUnit(""));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: CSSKeywords.startsWithFunction - function detection" {
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgb(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgba(255, 0, 0, 0.5)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("url(image.png)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("calc(100% - 20px)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("var(--custom-property)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("linear-gradient(to right, red, blue)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("custom-function(args)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("unknown(test)"));
|
||||
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("not-a-function"));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("missing-paren)"));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("missing-close("));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction(""));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("rgb"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isNumericWithUnit - whitespace handling" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10 px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("10px "));
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(" 10 px "));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - whitespace edge cases" {
|
||||
var result = CSSValueAnalyzer.extractImportant(" ");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("\t\n\r !important\t\n");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red\t!important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - mixed case handling" {
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#AbC"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#123aBc"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#FFffFF"));
|
||||
try testing.expect(CSSValueAnalyzer.isHexColor("#000FFF"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: edge case - very long inputs" {
|
||||
const long_valid = "a" ** 1000 ++ "px";
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(long_valid)); // not numeric
|
||||
|
||||
const long_property = "a-" ** 100 ++ "property";
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName(long_property));
|
||||
|
||||
const long_hex = "#" ++ "a" ** 20;
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(long_hex));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: boundary conditions - numeric parsing" {
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit(".0px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("999999999px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1.7976931348623157e+308px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("0.000000001px"));
|
||||
try testing.expect(CSSValueAnalyzer.isNumericWithUnit("1e-100px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: extractImportant - malformed important declarations" {
|
||||
var result = CSSValueAnalyzer.extractImportant("red ! important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red ! important", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !Important");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red !Important", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !IMPORTANT");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("red !IMPORTANT", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("!importantred");
|
||||
try testing.expect(!result.is_important);
|
||||
try testing.expectEqual("!importantred", result.value);
|
||||
|
||||
result = CSSValueAnalyzer.extractImportant("red !important !important");
|
||||
try testing.expect(result.is_important);
|
||||
try testing.expectEqual("red !important", result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isMultiValueProperty - complex spacing scenarios" {
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px"));
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("solid red"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty(" 10px 20px "));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\t20px"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("10px\n20px"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("10px 20px 30px"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isAlreadyQuoted - edge cases with quotes" {
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"'hello'\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'\"hello\"'"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"hello\\\"world\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'hello\\'world'"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("\"hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello\""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("'hello"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("hello'"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("\"a\""));
|
||||
try testing.expect(CSSValueAnalyzer.isAlreadyQuoted("'b'"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: needsQuotes - function and URL edge cases" {
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0)"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("url(path with spaces.jpg)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("linear-gradient(to right, red, blue)"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes("rgb(255, 0, 0"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: escapeCSSValue - control characters and Unicode" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
var result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\ttab");
|
||||
try testing.expectEqual("\"test\\9 tab\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\rreturn");
|
||||
try testing.expectEqual("\"test\\D return\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x00null");
|
||||
try testing.expectEqual("\"test\\0null\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\x7Fdel");
|
||||
try testing.expectEqual("\"test\\7f del\"", result);
|
||||
|
||||
result = try CSSValueAnalyzer.escapeCSSValue(allocator, "test\"quote\nline\\back");
|
||||
try testing.expectEqual("\"test\\\"quote\\A line\\\\back\"", result);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isValidPropertyName - CSS custom properties and vendor prefixes" {
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--custom-color"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--my-variable"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("--123"));
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-webkit-transform"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-moz-border-radius"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-ms-filter"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("-o-transition"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-123invalid"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("--"));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName("-"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: startsWithFunction - case sensitivity and partial matches" {
|
||||
try testing.expect(CSSKeywords.startsWithFunction("RGB(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("Rgb(255, 0, 0)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("URL(image.png)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rg(something)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("ur(something)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgb(1,2,3)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("rgba(1,2,3,4)"));
|
||||
|
||||
try testing.expect(CSSKeywords.startsWithFunction("my-custom-function(args)"));
|
||||
try testing.expect(CSSKeywords.startsWithFunction("function-with-dashes(test)"));
|
||||
|
||||
try testing.expect(!CSSKeywords.startsWithFunction("123function(test)"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: isHexColor - Unicode and invalid characters" {
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#ghijkl"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#12345g"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#xyz"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#АВС"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#1234567g"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("#g2345678"));
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: complex integration scenarios" {
|
||||
const allocator = testing.arena_allocator;
|
||||
|
||||
try testing.expect(CSSValueAnalyzer.isMultiValueProperty("rgb(255,0,0) url(bg.jpg)"));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("calc(100% - 20px)"));
|
||||
|
||||
const result = try CSSValueAnalyzer.escapeCSSValue(allocator, "fake(function with spaces");
|
||||
try testing.expectEqual("\"fake(function with spaces\"", result);
|
||||
|
||||
const important_result = CSSValueAnalyzer.extractImportant("rgb(255,0,0) !important");
|
||||
try testing.expect(important_result.is_important);
|
||||
try testing.expectEqual("rgb(255,0,0)", important_result.value);
|
||||
}
|
||||
|
||||
test "CSSValueAnalyzer: performance edge cases - empty and minimal inputs" {
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted(""));
|
||||
try testing.expect(!CSSValueAnalyzer.isValidPropertyName(""));
|
||||
try testing.expect(CSSValueAnalyzer.needsQuotes(""));
|
||||
try testing.expect(!CSSKeywords.isKnownKeyword(""));
|
||||
try testing.expect(!CSSKeywords.containsSpecialChar(""));
|
||||
try testing.expect(!CSSKeywords.isValidUnit(""));
|
||||
try testing.expect(!CSSKeywords.startsWithFunction(""));
|
||||
|
||||
try testing.expect(!CSSValueAnalyzer.isNumericWithUnit("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isHexColor("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isMultiValueProperty("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.isAlreadyQuoted("a"));
|
||||
try testing.expect(CSSValueAnalyzer.isValidPropertyName("a"));
|
||||
try testing.expect(!CSSValueAnalyzer.needsQuotes("a"));
|
||||
}
|
||||
79
src/browser/datauri.zig
Normal file
79
src/browser/datauri.zig
Normal file
@@ -0,0 +1,79 @@
|
||||
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));
|
||||
}
|
||||
95
src/browser/dom/attribute.zig
Normal file
95
src/browser/dom/attribute.zig
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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 Node = @import("node.zig").Node;
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#attr
|
||||
pub const Attr = struct {
|
||||
pub const Self = parser.Attribute;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn get_namespaceURI(self: *parser.Attribute) !?[]const u8 {
|
||||
return try parser.nodeGetNamespace(parser.attributeToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_prefix(self: *parser.Attribute) !?[]const u8 {
|
||||
return try parser.nodeGetPrefix(parser.attributeToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_localName(self: *parser.Attribute) ![]const u8 {
|
||||
return try parser.nodeLocalName(parser.attributeToNode(self));
|
||||
}
|
||||
|
||||
pub fn get_name(self: *parser.Attribute) ![]const u8 {
|
||||
return try parser.attributeGetName(self);
|
||||
}
|
||||
|
||||
pub fn get_value(self: *parser.Attribute) !?[]const u8 {
|
||||
return try parser.attributeGetValue(self);
|
||||
}
|
||||
|
||||
pub fn set_value(self: *parser.Attribute, v: []const u8) !?[]const u8 {
|
||||
try parser.attributeSetValue(self, v);
|
||||
return v;
|
||||
}
|
||||
|
||||
pub fn get_ownerElement(self: *parser.Attribute) !?*parser.Element {
|
||||
return try parser.attributeGetOwnerElement(self);
|
||||
}
|
||||
|
||||
pub fn get_specified(_: *parser.Attribute) bool {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Attribute" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let a = document.createAttributeNS('foo', 'bar')", "undefined" },
|
||||
.{ "a.namespaceURI", "foo" },
|
||||
.{ "a.prefix", "null" },
|
||||
.{ "a.localName", "bar" },
|
||||
.{ "a.name", "bar" },
|
||||
.{ "a.value", "" },
|
||||
// TODO: libdom has a bug here: the created attr has no parent, it
|
||||
// causes a panic w/ libdom when setting the value.
|
||||
//.{ "a.value = 'nok'", "nok" },
|
||||
.{ "a.ownerElement", "null" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let b = document.getElementById('link').getAttributeNode('class')", "undefined" },
|
||||
.{ "b.name", "class" },
|
||||
.{ "b.value", "ok" },
|
||||
.{ "b.value = 'nok'", "nok" },
|
||||
.{ "b.value", "nok" },
|
||||
.{ "b.value = null", "null" },
|
||||
.{ "b.value", "null" },
|
||||
.{ "b.value = 'ok'", "ok" },
|
||||
.{ "b.ownerElement.id", "link" },
|
||||
}, .{});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -16,13 +16,13 @@
|
||||
// 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/>.
|
||||
|
||||
// Gets the Parent of child.
|
||||
// HtmlElement.of(script) -> *HTMLElement
|
||||
pub fn Struct(comptime T: type) type {
|
||||
return switch (@typeInfo(T)) {
|
||||
.pointer => |ptr| ptr.child,
|
||||
.@"struct" => T,
|
||||
.void => T,
|
||||
else => unreachable,
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const Text = @import("text.zig").Text;
|
||||
|
||||
// https://dom.spec.whatwg.org/#cdatasection
|
||||
pub const CDATASection = struct {
|
||||
pub const Self = parser.CDATASection;
|
||||
pub const prototype = *Text;
|
||||
pub const subtype = .node;
|
||||
};
|
||||
}
|
||||
196
src/browser/dom/character_data.zig
Normal file
196
src/browser/dom/character_data.zig
Normal file
@@ -0,0 +1,196 @@
|
||||
// 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 Comment = @import("comment.zig").Comment;
|
||||
const Text = @import("text.zig");
|
||||
const ProcessingInstruction = @import("processing_instruction.zig").ProcessingInstruction;
|
||||
const HTMLElem = @import("../html/elements.zig");
|
||||
|
||||
// CharacterData interfaces
|
||||
pub const Interfaces = .{
|
||||
Comment,
|
||||
Text.Text,
|
||||
Text.Interfaces,
|
||||
ProcessingInstruction,
|
||||
};
|
||||
|
||||
// CharacterData implementation
|
||||
pub const CharacterData = struct {
|
||||
pub const Self = parser.CharacterData;
|
||||
pub const prototype = *Node;
|
||||
pub const subtype = .node;
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
// Read attributes
|
||||
|
||||
pub fn get_length(self: *parser.CharacterData) !u32 {
|
||||
return try parser.characterDataLength(self);
|
||||
}
|
||||
|
||||
pub fn get_nextElementSibling(self: *parser.CharacterData) !?HTMLElem.Union {
|
||||
const res = try parser.nodeNextElementSibling(parser.characterDataToNode(self));
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
|
||||
}
|
||||
|
||||
pub fn get_previousElementSibling(self: *parser.CharacterData) !?HTMLElem.Union {
|
||||
const res = try parser.nodePreviousElementSibling(parser.characterDataToNode(self));
|
||||
if (res == null) {
|
||||
return null;
|
||||
}
|
||||
return try HTMLElem.toInterface(HTMLElem.Union, res.?);
|
||||
}
|
||||
|
||||
// Read/Write attributes
|
||||
|
||||
pub fn get_data(self: *parser.CharacterData) ![]const u8 {
|
||||
return try parser.characterDataData(self);
|
||||
}
|
||||
|
||||
pub fn set_data(self: *parser.CharacterData, data: []const u8) !void {
|
||||
return try parser.characterDataSetData(self, data);
|
||||
}
|
||||
|
||||
// JS methods
|
||||
// ----------
|
||||
|
||||
pub fn _appendData(self: *parser.CharacterData, data: []const u8) !void {
|
||||
return try parser.characterDataAppendData(self, data);
|
||||
}
|
||||
|
||||
pub fn _deleteData(self: *parser.CharacterData, offset: u32, count: u32) !void {
|
||||
return try parser.characterDataDeleteData(self, offset, count);
|
||||
}
|
||||
|
||||
pub fn _insertData(self: *parser.CharacterData, offset: u32, data: []const u8) !void {
|
||||
return try parser.characterDataInsertData(self, offset, data);
|
||||
}
|
||||
|
||||
pub fn _replaceData(self: *parser.CharacterData, offset: u32, count: u32, data: []const u8) !void {
|
||||
return try parser.characterDataReplaceData(self, offset, count, data);
|
||||
}
|
||||
|
||||
pub fn _substringData(self: *parser.CharacterData, offset: u32, count: u32) ![]const u8 {
|
||||
return try parser.characterDataSubstringData(self, offset, count);
|
||||
}
|
||||
|
||||
// netsurf's CharacterData (text, comment) doesn't implement the
|
||||
// dom_node_get_attributes and thus will crash if we try to call nodeIsEqualNode.
|
||||
pub fn _isEqualNode(self: *parser.CharacterData, other_node: *parser.Node) !bool {
|
||||
if (try parser.nodeType(@alignCast(@ptrCast(self))) != try parser.nodeType(other_node)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const other: *parser.CharacterData = @ptrCast(other_node);
|
||||
if (std.mem.eql(u8, try get_data(self), try get_data(other)) == false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn _before(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.characterDataToNode(self);
|
||||
return Node.before(ref_node, nodes);
|
||||
}
|
||||
|
||||
pub fn _after(self: *parser.CharacterData, nodes: []const Node.NodeOrText) !void {
|
||||
const ref_node = parser.characterDataToNode(self);
|
||||
return Node.after(ref_node, nodes);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.CharacterData" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let link = document.getElementById('link')", "undefined" },
|
||||
.{ "let cdata = link.firstChild", "undefined" },
|
||||
.{ "cdata.data", "OK" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.data = 'OK modified'", "OK modified" },
|
||||
.{ "cdata.data === 'OK modified'", "true" },
|
||||
.{ "cdata.data = 'OK'", "OK" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.length === 2", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.nextElementSibling === null", "true" },
|
||||
// create a next element
|
||||
.{ "let next = document.createElement('a')", "undefined" },
|
||||
.{ "link.appendChild(next, cdata) !== undefined", "true" },
|
||||
.{ "cdata.nextElementSibling.localName === 'a' ", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.previousElementSibling === null", "true" },
|
||||
// create a prev element
|
||||
.{ "let prev = document.createElement('div')", "undefined" },
|
||||
.{ "link.insertBefore(prev, cdata) !== undefined", "true" },
|
||||
.{ "cdata.previousElementSibling.localName === 'div' ", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.appendData(' modified')", "undefined" },
|
||||
.{ "cdata.data === 'OK modified' ", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.deleteData('OK'.length, ' modified'.length)", "undefined" },
|
||||
.{ "cdata.data == 'OK'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.insertData('OK'.length-1, 'modified')", "undefined" },
|
||||
.{ "cdata.data == 'OmodifiedK'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.replaceData('OK'.length-1, 'modified'.length, 'replaced')", "undefined" },
|
||||
.{ "cdata.data == 'OreplacedK'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
|
||||
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "cdata.substringData('OK'.length-1, 'replaced'.length) == 'replaced'", "true" },
|
||||
.{ "cdata.substringData('OK'.length-1, 0) == ''", "true" },
|
||||
}, .{});
|
||||
}
|
||||
54
src/browser/dom/comment.zig
Normal file
54
src/browser/dom/comment.zig
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const CharacterData = @import("character_data.zig").CharacterData;
|
||||
|
||||
const Page = @import("../page.zig").Page;
|
||||
|
||||
// https://dom.spec.whatwg.org/#interface-comment
|
||||
pub const Comment = struct {
|
||||
pub const Self = parser.Comment;
|
||||
pub const prototype = *CharacterData;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn constructor(data: ?[]const u8, page: *const Page) !*parser.Comment {
|
||||
return parser.documentCreateComment(
|
||||
parser.documentHTMLToDocument(page.window.document),
|
||||
data orelse "",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Comment" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let comment = new Comment('foo')", "undefined" },
|
||||
.{ "comment.data", "foo" },
|
||||
|
||||
.{ "let emptycomment = new Comment()", "undefined" },
|
||||
.{ "emptycomment.data", "" },
|
||||
}, .{});
|
||||
}
|
||||
80
src/browser/dom/css.zig
Normal file
80
src/browser/dom/css.zig
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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 css = @import("../css/css.zig");
|
||||
const Node = @import("../css/libdom.zig").Node;
|
||||
const NodeList = @import("nodelist.zig").NodeList;
|
||||
|
||||
const MatchFirst = struct {
|
||||
n: ?*parser.Node = null,
|
||||
|
||||
pub fn match(m: *MatchFirst, n: Node) !void {
|
||||
m.n = n.node;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn querySelector(alloc: std.mem.Allocator, n: *parser.Node, selector: []const u8) !?*parser.Node {
|
||||
const ps = try css.parse(alloc, selector, .{ .accept_pseudo_elts = true });
|
||||
defer ps.deinit(alloc);
|
||||
|
||||
var m = MatchFirst{};
|
||||
|
||||
_ = try css.matchFirst(ps, Node{ .node = n }, &m);
|
||||
return m.n;
|
||||
}
|
||||
|
||||
const MatchAll = struct {
|
||||
alloc: std.mem.Allocator,
|
||||
nl: NodeList,
|
||||
|
||||
fn init(alloc: std.mem.Allocator) MatchAll {
|
||||
return .{
|
||||
.alloc = alloc,
|
||||
.nl = .{},
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(m: *MatchAll) void {
|
||||
m.nl.deinit(m.alloc);
|
||||
}
|
||||
|
||||
pub fn match(m: *MatchAll, n: Node) !void {
|
||||
try m.nl.append(m.alloc, n.node);
|
||||
}
|
||||
|
||||
fn toOwnedList(m: *MatchAll) NodeList {
|
||||
// reset it.
|
||||
defer m.nl = .{};
|
||||
return m.nl;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn querySelectorAll(alloc: std.mem.Allocator, n: *parser.Node, selector: []const u8) !NodeList {
|
||||
const ps = try css.parse(alloc, selector, .{ .accept_pseudo_elts = true });
|
||||
defer ps.deinit(alloc);
|
||||
|
||||
var m = MatchAll.init(alloc);
|
||||
defer m.deinit();
|
||||
|
||||
try css.matchAll(ps, Node{ .node = n }, &m);
|
||||
return m.toOwnedList();
|
||||
}
|
||||
474
src/browser/dom/document.zig
Normal file
474
src/browser/dom/document.zig
Normal file
@@ -0,0 +1,474 @@
|
||||
// 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 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 Elements = @import("../html/elements.zig");
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
|
||||
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),
|
||||
&Elements.createElement,
|
||||
);
|
||||
|
||||
// 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 {
|
||||
const e = try parser.documentCreateElement(self, tag_name);
|
||||
return try 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.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);
|
||||
}
|
||||
};
|
||||
|
||||
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",
|
||||
},
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "document.activeElement === document.body", "true" },
|
||||
.{ "document.getElementById('link').focus()", "undefined" },
|
||||
.{ "document.activeElement === document.getElementById('link')", "true" },
|
||||
}, .{});
|
||||
|
||||
// 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, .{});
|
||||
}
|
||||
74
src/browser/dom/document_fragment.zig
Normal file
74
src/browser/dom/document_fragment.zig
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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" },
|
||||
}, .{});
|
||||
}
|
||||
80
src/browser/dom/document_type.zig
Normal file
80
src/browser/dom/document_type.zig
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
47
src/browser/dom/dom.zig
Normal file
47
src/browser/dom/dom.zig
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
const EventTarget = @import("event_target.zig").EventTarget;
|
||||
const DOMImplementation = @import("implementation.zig").DOMImplementation;
|
||||
const NamedNodeMap = @import("namednodemap.zig").NamedNodeMap;
|
||||
const DOMTokenList = @import("token_list.zig");
|
||||
const NodeList = @import("nodelist.zig");
|
||||
const Node = @import("node.zig");
|
||||
const MutationObserver = @import("mutation_observer.zig");
|
||||
const IntersectionObserver = @import("intersection_observer.zig");
|
||||
const DOMParser = @import("dom_parser.zig").DOMParser;
|
||||
const TreeWalker = @import("tree_walker.zig").TreeWalker;
|
||||
const NodeFilter = @import("node_filter.zig").NodeFilter;
|
||||
|
||||
pub const Interfaces = .{
|
||||
DOMException,
|
||||
EventTarget,
|
||||
DOMImplementation,
|
||||
NamedNodeMap,
|
||||
NamedNodeMap.Iterator,
|
||||
DOMTokenList.Interfaces,
|
||||
NodeList.Interfaces,
|
||||
Node.Node,
|
||||
Node.Interfaces,
|
||||
MutationObserver.Interfaces,
|
||||
IntersectionObserver.Interfaces,
|
||||
DOMParser,
|
||||
TreeWalker,
|
||||
NodeFilter,
|
||||
};
|
||||
47
src/browser/dom/dom_parser.zig
Normal file
47
src/browser/dom/dom_parser.zig
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/DOMParser
|
||||
pub const DOMParser = struct {
|
||||
pub fn constructor() !DOMParser {
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn _parseFromString(_: *DOMParser, string: []const u8, mime_type: []const u8) !*parser.DocumentHTML {
|
||||
if (!std.mem.eql(u8, mime_type, "text/html")) {
|
||||
// TODO: Support XML
|
||||
return error.TypeError;
|
||||
}
|
||||
const Elements = @import("../html/elements.zig");
|
||||
return try parser.documentHTMLParseFromStr(string, &Elements.createElement);
|
||||
}
|
||||
};
|
||||
|
||||
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]" },
|
||||
}, .{});
|
||||
}
|
||||
669
src/browser/dom/element.zig
Normal file
669
src/browser/dom/element.zig
Normal file
@@ -0,0 +1,669 @@
|
||||
// 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");
|
||||
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,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// get fragment body children
|
||||
const children = try parser.documentFragmentBodyChildren(fragment) orelse return;
|
||||
|
||||
// append children to the node
|
||||
const ln = try parser.nodeListLength(children);
|
||||
for (0..ln) |_| {
|
||||
// always index 0, because ndoeAppendChild 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;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _hasAttributes(self: *parser.Element) !bool {
|
||||
return try parser.nodeHasAttributes(parser.elementToNode(self));
|
||||
}
|
||||
|
||||
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.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 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
237
src/browser/dom/event_target.zig
Normal file
237
src/browser/dom/event_target.zig
Normal file
@@ -0,0 +1,237 @@
|
||||
// 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");
|
||||
|
||||
// EventTarget interfaces
|
||||
pub const Union = Nod.Union;
|
||||
|
||||
// EventTarget implementation
|
||||
pub const EventTarget = struct {
|
||||
pub const Self = parser.EventTarget;
|
||||
pub const Exception = DOMException;
|
||||
|
||||
pub fn toInterface(et: *parser.EventTarget, page: *Page) !Union {
|
||||
// Not all targets are *parser.Nodes. page.zig emits a "load" event
|
||||
// where the target is a Window, which cannot be cast directly to a node.
|
||||
// Ideally, we'd remove this duality. Failing that, we'll need to embed
|
||||
// data into the *parser.EventTarget should we need this for other types.
|
||||
// For now, for the Window, which is a singleton, we can do this:
|
||||
if (@intFromPtr(et) == @intFromPtr(&page.window.base)) {
|
||||
return .{ .Window = &page.window };
|
||||
}
|
||||
return 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 },
|
||||
}, .{});
|
||||
}
|
||||
191
src/browser/dom/exceptions.zig
Normal file
191
src/browser/dom/exceptions.zig
Normal file
@@ -0,0 +1,191 @@
|
||||
// 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 allocPrint = std.fmt.allocPrint;
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
// https://webidl.spec.whatwg.org/#idl-DOMException
|
||||
pub const DOMException = struct {
|
||||
err: parser.DOMError,
|
||||
str: []const u8,
|
||||
|
||||
pub const ErrorSet = parser.DOMError;
|
||||
|
||||
// static attributes
|
||||
pub const _INDEX_SIZE_ERR = 1;
|
||||
pub const _DOMSTRING_SIZE_ERR = 2;
|
||||
pub const _HIERARCHY_REQUEST_ERR = 3;
|
||||
pub const _WRONG_DOCUMENT_ERR = 4;
|
||||
pub const _INVALID_CHARACTER_ERR = 5;
|
||||
pub const _NO_DATA_ALLOWED_ERR = 6;
|
||||
pub const _NO_MODIFICATION_ALLOWED_ERR = 7;
|
||||
pub const _NOT_FOUND_ERR = 8;
|
||||
pub const _NOT_SUPPORTED_ERR = 9;
|
||||
pub const _INUSE_ATTRIBUTE_ERR = 10;
|
||||
pub const _INVALID_STATE_ERR = 11;
|
||||
pub const _SYNTAX_ERR = 12;
|
||||
pub const _INVALID_MODIFICATION_ERR = 13;
|
||||
pub const _NAMESPACE_ERR = 14;
|
||||
pub const _INVALID_ACCESS_ERR = 15;
|
||||
pub const _VALIDATION_ERR = 16;
|
||||
pub const _TYPE_MISMATCH_ERR = 17;
|
||||
pub const _SECURITY_ERR = 18;
|
||||
pub const _NETWORK_ERR = 19;
|
||||
pub const _ABORT_ERR = 20;
|
||||
pub const _URL_MISMATCH_ERR = 21;
|
||||
pub const _QUOTA_EXCEEDED_ERR = 22;
|
||||
pub const _TIMEOUT_ERR = 23;
|
||||
pub const _INVALID_NODE_TYPE_ERR = 24;
|
||||
pub const _DATA_CLONE_ERR = 25;
|
||||
|
||||
// TODO: deinit
|
||||
pub fn init(alloc: std.mem.Allocator, err: anyerror, callerName: []const u8) !DOMException {
|
||||
const errCast = @as(parser.DOMError, @errorCast(err));
|
||||
const errName = DOMException.name(errCast);
|
||||
const str = switch (errCast) {
|
||||
error.HierarchyRequest => try allocPrint(
|
||||
alloc,
|
||||
"{s}: Failed to execute '{s}' on 'Node': The new child element contains the parent.",
|
||||
.{ errName, callerName },
|
||||
),
|
||||
error.NoError => unreachable,
|
||||
else => try allocPrint(
|
||||
alloc,
|
||||
"{s}: TODO message", // TODO: implement other messages
|
||||
.{DOMException.name(errCast)},
|
||||
),
|
||||
};
|
||||
return .{ .err = errCast, .str = str };
|
||||
}
|
||||
|
||||
fn name(err: parser.DOMError) []const u8 {
|
||||
return switch (err) {
|
||||
error.IndexSize => "IndexSizeError",
|
||||
error.StringSize => "StringSizeError",
|
||||
error.HierarchyRequest => "HierarchyRequestError",
|
||||
error.WrongDocument => "WrongDocumentError",
|
||||
error.InvalidCharacter => "InvalidCharacterError",
|
||||
error.NoDataAllowed => "NoDataAllowedError",
|
||||
error.NoModificationAllowed => "NoModificationAllowedError",
|
||||
error.NotFound => "NotFoundError",
|
||||
error.NotSupported => "NotSupportedError",
|
||||
error.InuseAttribute => "InuseAttributeError",
|
||||
error.InvalidState => "InvalidStateError",
|
||||
error.Syntax => "SyntaxError",
|
||||
error.InvalidModification => "InvalidModificationError",
|
||||
error.Namespace => "NamespaceError",
|
||||
error.InvalidAccess => "InvalidAccessError",
|
||||
error.Validation => "ValidationError",
|
||||
error.TypeMismatch => "TypeMismatchError",
|
||||
error.Security => "SecurityError",
|
||||
error.Network => "NetworkError",
|
||||
error.Abort => "AbortError",
|
||||
error.URLismatch => "URLismatchError",
|
||||
error.QuotaExceeded => "QuotaExceededError",
|
||||
error.Timeout => "TimeoutError",
|
||||
error.InvalidNodeType => "InvalidNodeTypeError",
|
||||
error.DataClone => "DataCloneError",
|
||||
error.NoError => unreachable,
|
||||
|
||||
// custom netsurf error
|
||||
error.UnspecifiedEventType => "UnspecifiedEventTypeError",
|
||||
error.DispatchRequest => "DispatchRequestError",
|
||||
error.NoMemory => "NoMemoryError",
|
||||
error.AttributeWrongType => "AttributeWrongTypeError",
|
||||
};
|
||||
}
|
||||
|
||||
// JS properties and methods
|
||||
|
||||
pub fn get_code(self: *const DOMException) u8 {
|
||||
return switch (self.err) {
|
||||
error.IndexSize => 1,
|
||||
error.StringSize => 2,
|
||||
error.HierarchyRequest => 3,
|
||||
error.WrongDocument => 4,
|
||||
error.InvalidCharacter => 5,
|
||||
error.NoDataAllowed => 6,
|
||||
error.NoModificationAllowed => 7,
|
||||
error.NotFound => 8,
|
||||
error.NotSupported => 9,
|
||||
error.InuseAttribute => 10,
|
||||
error.InvalidState => 11,
|
||||
error.Syntax => 12,
|
||||
error.InvalidModification => 13,
|
||||
error.Namespace => 14,
|
||||
error.InvalidAccess => 15,
|
||||
error.Validation => 16,
|
||||
error.TypeMismatch => 17,
|
||||
error.Security => 18,
|
||||
error.Network => 19,
|
||||
error.Abort => 20,
|
||||
error.URLismatch => 21,
|
||||
error.QuotaExceeded => 22,
|
||||
error.Timeout => 23,
|
||||
error.InvalidNodeType => 24,
|
||||
error.DataClone => 25,
|
||||
error.NoError => unreachable,
|
||||
|
||||
// custom netsurf error
|
||||
error.UnspecifiedEventType => 128,
|
||||
error.DispatchRequest => 129,
|
||||
error.NoMemory => 130,
|
||||
error.AttributeWrongType => 131,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_name(self: *const DOMException) []const u8 {
|
||||
return DOMException.name(self.err);
|
||||
}
|
||||
|
||||
pub fn get_message(self: *const DOMException) []const u8 {
|
||||
const errName = DOMException.name(self.err);
|
||||
return self.str[errName.len + 2 ..];
|
||||
}
|
||||
|
||||
pub fn _toString(self: *const DOMException) []const u8 {
|
||||
return self.str;
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Exception" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
const err = "Failed to execute 'appendChild' on 'Node': The new child element contains the parent.";
|
||||
try runner.testCases(&.{
|
||||
.{ "let content = document.getElementById('content')", "undefined" },
|
||||
.{ "let link = document.getElementById('link')", "undefined" },
|
||||
// HierarchyRequestError
|
||||
.{
|
||||
\\ var he;
|
||||
\\ try { link.appendChild(content) } catch (error) { he = error}
|
||||
\\ he.name
|
||||
,
|
||||
"HierarchyRequestError",
|
||||
},
|
||||
.{ "he.code", "3" },
|
||||
.{ "he.message", err },
|
||||
.{ "he.toString()", "HierarchyRequestError: " ++ err },
|
||||
.{ "he instanceof DOMException", "true" },
|
||||
.{ "he instanceof Error", "true" },
|
||||
}, .{});
|
||||
}
|
||||
500
src/browser/dom/html_collection.zig
Normal file
500
src/browser/dom/html_collection.zig
Normal file
@@ -0,0 +1,500 @@
|
||||
// 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 Element = @import("element.zig").Element;
|
||||
const Union = @import("element.zig").Union;
|
||||
const JsThis = @import("../env.zig").JsThis;
|
||||
const Walker = @import("walker.zig").Walker;
|
||||
|
||||
const Matcher = union(enum) {
|
||||
matchByName: MatchByName,
|
||||
matchByTagName: MatchByTagName,
|
||||
matchByClassName: MatchByClassName,
|
||||
matchByLinks: MatchByLinks,
|
||||
matchByAnchors: MatchByAnchors,
|
||||
matchTrue: struct {},
|
||||
matchFalse: struct {},
|
||||
|
||||
pub fn match(self: Matcher, node: *parser.Node) !bool {
|
||||
switch (self) {
|
||||
.matchTrue => return true,
|
||||
.matchFalse => return false,
|
||||
.matchByLinks => return MatchByLinks.match(node),
|
||||
.matchByAnchors => return MatchByAnchors.match(node),
|
||||
inline else => |m| return m.match(node),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub const MatchByTagName = struct {
|
||||
// tag is used to select node against their name.
|
||||
// tag comparison is case insensitive.
|
||||
tag: []const u8,
|
||||
is_wildcard: bool,
|
||||
|
||||
fn init(arena: Allocator, tag_name: []const u8) !MatchByTagName {
|
||||
if (std.mem.eql(u8, tag_name, "*")) {
|
||||
return .{ .tag = "*", .is_wildcard = true };
|
||||
}
|
||||
|
||||
return .{
|
||||
.tag = try arena.dupe(u8, tag_name),
|
||||
.is_wildcard = false,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn match(self: MatchByTagName, node: *parser.Node) !bool {
|
||||
return self.is_wildcard or std.ascii.eqlIgnoreCase(self.tag, try parser.nodeName(node));
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionByTagName(
|
||||
arena: Allocator,
|
||||
root: ?*parser.Node,
|
||||
tag_name: []const u8,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchByTagName = try MatchByTagName.init(arena, tag_name) },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
pub const MatchByClassName = struct {
|
||||
class_names: []const u8,
|
||||
|
||||
fn init(arena: Allocator, class_names: []const u8) !MatchByClassName {
|
||||
return .{
|
||||
.class_names = try arena.dupe(u8, class_names),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn match(self: MatchByClassName, node: *parser.Node) !bool {
|
||||
const e = parser.nodeToElement(node);
|
||||
|
||||
var it = std.mem.splitScalar(u8, self.class_names, ' ');
|
||||
while (it.next()) |c| {
|
||||
if (!try parser.elementHasClass(e, c)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionByClassName(
|
||||
arena: Allocator,
|
||||
root: ?*parser.Node,
|
||||
classNames: []const u8,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchByClassName = try MatchByClassName.init(arena, classNames) },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
pub const MatchByName = struct {
|
||||
name: []const u8,
|
||||
|
||||
fn init(arena: Allocator, name: []const u8) !MatchByName {
|
||||
return .{
|
||||
.name = try arena.dupe(u8, name),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn match(self: MatchByName, node: *parser.Node) !bool {
|
||||
const e = parser.nodeToElement(node);
|
||||
const nname = try parser.elementGetAttribute(e, "name") orelse return false;
|
||||
return std.mem.eql(u8, self.name, nname);
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionByName(
|
||||
arena: Allocator,
|
||||
root: ?*parser.Node,
|
||||
name: []const u8,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchByName = try MatchByName.init(arena, name) },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
// HTMLAllCollection is a special type: instances of it are falsy. It's the only
|
||||
// object in the WebAPI that behaves like this - in fact, it's even a special
|
||||
// case in the JavaScript spec.
|
||||
// This is important, because a lot of browser detection rely on this behavior
|
||||
// to determine what browser is running.
|
||||
|
||||
// It's also possible to use an instance like a function:
|
||||
// document.all(3)
|
||||
// document.all('some_id')
|
||||
pub const HTMLAllCollection = struct {
|
||||
pub const prototype = *HTMLCollection;
|
||||
|
||||
proto: HTMLCollection,
|
||||
|
||||
pub const mark_as_undetectable = true;
|
||||
|
||||
pub fn init(root: ?*parser.Node) HTMLAllCollection {
|
||||
return .{ .proto = .{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchTrue = .{} },
|
||||
.include_root = true,
|
||||
} };
|
||||
}
|
||||
|
||||
const CAllAsFunctionArg = union(enum) {
|
||||
index: u32,
|
||||
id: []const u8,
|
||||
};
|
||||
|
||||
pub fn jsCallAsFunction(self: *HTMLAllCollection, arg: CAllAsFunctionArg) !?Union {
|
||||
return switch (arg) {
|
||||
.index => |i| self.proto._item(i),
|
||||
.id => |id| self.proto._namedItem(id),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionChildren(
|
||||
root: ?*parser.Node,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerChildren = .{} },
|
||||
.matcher = .{ .matchTrue = .{} },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn HTMLCollectionEmpty() !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = null,
|
||||
.walker = .{ .walkerNone = .{} },
|
||||
.matcher = .{ .matchFalse = .{} },
|
||||
.include_root = false,
|
||||
};
|
||||
}
|
||||
|
||||
// MatchByLinks matches the a and area elements in the Document that have href
|
||||
// attributes.
|
||||
// https://html.spec.whatwg.org/#dom-document-links
|
||||
pub const MatchByLinks = struct {
|
||||
pub fn match(node: *parser.Node) !bool {
|
||||
const tag = try parser.nodeName(node);
|
||||
if (!std.ascii.eqlIgnoreCase(tag, "a") and !std.ascii.eqlIgnoreCase(tag, "area")) {
|
||||
return false;
|
||||
}
|
||||
const elem = @as(*parser.Element, @ptrCast(node));
|
||||
return parser.elementHasAttribute(elem, "href");
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionByLinks(
|
||||
root: ?*parser.Node,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchByLinks = MatchByLinks{} },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
// MatchByAnchors matches the a elements in the Document that have name
|
||||
// attributes.
|
||||
// https://html.spec.whatwg.org/#dom-document-anchors
|
||||
pub const MatchByAnchors = struct {
|
||||
pub fn match(node: *parser.Node) !bool {
|
||||
const tag = try parser.nodeName(node);
|
||||
if (!std.ascii.eqlIgnoreCase(tag, "a")) return false;
|
||||
|
||||
const elem = @as(*parser.Element, @ptrCast(node));
|
||||
return parser.elementHasAttribute(elem, "name");
|
||||
}
|
||||
};
|
||||
|
||||
pub fn HTMLCollectionByAnchors(
|
||||
root: ?*parser.Node,
|
||||
include_root: bool,
|
||||
) !HTMLCollection {
|
||||
return HTMLCollection{
|
||||
.root = root,
|
||||
.walker = .{ .walkerDepthFirst = .{} },
|
||||
.matcher = .{ .matchByAnchors = MatchByAnchors{} },
|
||||
.include_root = include_root,
|
||||
};
|
||||
}
|
||||
|
||||
pub const HTMLCollectionIterator = struct {
|
||||
coll: *HTMLCollection,
|
||||
index: u32 = 0,
|
||||
|
||||
pub const Return = struct {
|
||||
value: ?Union,
|
||||
done: bool,
|
||||
};
|
||||
|
||||
pub fn _next(self: *HTMLCollectionIterator) !Return {
|
||||
const e = try self.coll._item(self.index);
|
||||
if (e == null) {
|
||||
return Return{
|
||||
.value = null,
|
||||
.done = true,
|
||||
};
|
||||
}
|
||||
|
||||
self.index += 1;
|
||||
return Return{
|
||||
.value = e,
|
||||
.done = false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// WEB IDL https://dom.spec.whatwg.org/#htmlcollection
|
||||
// HTMLCollection is re implemented in zig here because libdom
|
||||
// dom_html_collection expects a comparison function callback as arguement.
|
||||
// But we wanted a dynamically comparison here, according to the match tagname.
|
||||
pub const HTMLCollection = struct {
|
||||
matcher: Matcher,
|
||||
walker: Walker,
|
||||
|
||||
root: ?*parser.Node,
|
||||
|
||||
// By default the HTMLCollection walk on the root's descendant only.
|
||||
// But on somes cases, like for dom document, we want to walk over the root
|
||||
// itself.
|
||||
include_root: bool = false,
|
||||
|
||||
// save a state for the collection to improve the _item speed.
|
||||
cur_idx: ?u32 = null,
|
||||
cur_node: ?*parser.Node = null,
|
||||
|
||||
// start returns the first node to walk on.
|
||||
fn start(self: *const HTMLCollection) !?*parser.Node {
|
||||
if (self.root == null) return null;
|
||||
|
||||
if (self.include_root) {
|
||||
return self.root.?;
|
||||
}
|
||||
|
||||
return try self.walker.get_next(self.root.?, null);
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *HTMLCollection) HTMLCollectionIterator {
|
||||
return HTMLCollectionIterator{
|
||||
.coll = self,
|
||||
};
|
||||
}
|
||||
|
||||
/// get_length computes the collection's length dynamically according to
|
||||
/// the current root structure.
|
||||
// TODO: nodes retrieved must be de-referenced.
|
||||
pub fn get_length(self: *HTMLCollection) !u32 {
|
||||
if (self.root == null) return 0;
|
||||
|
||||
var len: u32 = 0;
|
||||
var node = try self.start() orelse return 0;
|
||||
|
||||
while (true) {
|
||||
if (try parser.nodeType(node) == .element) {
|
||||
if (try self.matcher.match(node)) {
|
||||
len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
node = try self.walker.get_next(self.root.?, node) orelse break;
|
||||
}
|
||||
|
||||
return len;
|
||||
}
|
||||
|
||||
pub fn item(self: *HTMLCollection, index: u32) !?*parser.Node {
|
||||
if (self.root == null) return null;
|
||||
|
||||
var i: u32 = 0;
|
||||
var node: *parser.Node = undefined;
|
||||
|
||||
// Use the current state to improve speed if possible.
|
||||
if (self.cur_idx != null and index >= self.cur_idx.?) {
|
||||
i = self.cur_idx.?;
|
||||
node = self.cur_node.?;
|
||||
} else {
|
||||
node = try self.start() orelse return null;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (try parser.nodeType(node) == .element) {
|
||||
if (try self.matcher.match(node)) {
|
||||
// check if we found the searched element.
|
||||
if (i == index) {
|
||||
// save the current state
|
||||
self.cur_node = node;
|
||||
self.cur_idx = i;
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
node = try self.walker.get_next(self.root.?, node) orelse break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn _item(self: *HTMLCollection, index: u32) !?Union {
|
||||
const node = try self.item(index) orelse return null;
|
||||
const e = @as(*parser.Element, @ptrCast(node));
|
||||
return try Element.toInterface(e);
|
||||
}
|
||||
|
||||
pub fn _namedItem(self: *const HTMLCollection, name: []const u8) !?Union {
|
||||
if (self.root == null) return null;
|
||||
if (name.len == 0) return null;
|
||||
|
||||
var node = try self.start() orelse return null;
|
||||
|
||||
while (true) {
|
||||
if (try parser.nodeType(node) == .element) {
|
||||
if (try self.matcher.match(node)) {
|
||||
const elem = @as(*parser.Element, @ptrCast(node));
|
||||
|
||||
var attr = try parser.elementGetAttribute(elem, "id");
|
||||
// check if the node id corresponds to the name argument.
|
||||
if (attr != null and std.mem.eql(u8, name, attr.?)) {
|
||||
return try Element.toInterface(elem);
|
||||
}
|
||||
|
||||
attr = try parser.elementGetAttribute(elem, "name");
|
||||
// check if the node id corresponds to the name argument.
|
||||
if (attr != null and std.mem.eql(u8, name, attr.?)) {
|
||||
return try Element.toInterface(elem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node = try self.walker.get_next(self.root.?, node) orelse break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
fn item_name(elt: *parser.Element) !?[]const u8 {
|
||||
if (try parser.elementGetAttribute(elt, "id")) |v| {
|
||||
return v;
|
||||
}
|
||||
if (try parser.elementGetAttribute(elt, "name")) |v| {
|
||||
return v;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
pub fn postAttach(self: *HTMLCollection, js_this: JsThis) !void {
|
||||
const len = try self.get_length();
|
||||
for (0..len) |i| {
|
||||
const node = try self.item(@intCast(i)) orelse unreachable;
|
||||
const e = @as(*parser.Element, @ptrCast(node));
|
||||
const as_interface = try Element.toInterface(e);
|
||||
try js_this.setIndex(@intCast(i), as_interface, .{});
|
||||
|
||||
if (try item_name(e)) |name| {
|
||||
// Even though an entry might have an empty id, the spec says
|
||||
// that namedItem("") should always return null
|
||||
if (name.len > 0) {
|
||||
// Named fields should not be enumerable (it is defined with
|
||||
// the LegacyUnenumerableNamedProperties flag.)
|
||||
try js_this.set(name, as_interface, .{ .DONT_ENUM = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.HTMLCollection" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let getElementsByTagName = document.getElementsByTagName('p')", "undefined" },
|
||||
.{ "getElementsByTagName.length", "2" },
|
||||
.{ "let getElementsByTagNameCI = document.getElementsByTagName('P')", "undefined" },
|
||||
.{ "getElementsByTagNameCI.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(0).localName", "html" },
|
||||
.{ "getElementsByTagNameAll.item(1).localName", "head" },
|
||||
.{ "getElementsByTagNameAll.item(0).localName", "html" },
|
||||
.{ "getElementsByTagNameAll.item(2).localName", "body" },
|
||||
.{ "getElementsByTagNameAll.item(3).localName", "div" },
|
||||
.{ "getElementsByTagNameAll.item(7).localName", "p" },
|
||||
.{ "getElementsByTagNameAll.namedItem('para-empty-child').localName", "span" },
|
||||
|
||||
// array like
|
||||
.{ "getElementsByTagNameAll[0].localName", "html" },
|
||||
.{ "getElementsByTagNameAll[7].localName", "p" },
|
||||
.{ "getElementsByTagNameAll[8]", "undefined" },
|
||||
.{ "getElementsByTagNameAll['para-empty-child'].localName", "span" },
|
||||
.{ "getElementsByTagNameAll['foo']", "undefined" },
|
||||
|
||||
.{ "document.getElementById('content').getElementsByTagName('*').length", "4" },
|
||||
.{ "document.getElementById('content').getElementsByTagName('p').length", "2" },
|
||||
.{ "document.getElementById('content').getElementsByTagName('div').length", "0" },
|
||||
|
||||
.{ "document.children.length", "1" },
|
||||
.{ "document.getElementById('content').children.length", "3" },
|
||||
|
||||
// check liveness
|
||||
.{ "let content = document.getElementById('content')", "undefined" },
|
||||
.{ "let pe = document.getElementById('para-empty')", "undefined" },
|
||||
.{ "let p = document.createElement('p')", "undefined" },
|
||||
.{ "p.textContent = 'OK live'", "OK live" },
|
||||
.{ "getElementsByTagName.item(1).textContent", " And" },
|
||||
.{ "content.appendChild(p) != undefined", "true" },
|
||||
.{ "getElementsByTagName.length", "3" },
|
||||
.{ "getElementsByTagName.item(2).textContent", "OK live" },
|
||||
.{ "content.insertBefore(p, pe) != undefined", "true" },
|
||||
.{ "getElementsByTagName.item(0).textContent", "OK live" },
|
||||
}, .{});
|
||||
}
|
||||
73
src/browser/dom/implementation.zig
Normal file
73
src/browser/dom/implementation.zig
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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 {
|
||||
const Elements = @import("../html/elements.zig");
|
||||
return try parser.domImplementationCreateHTMLDocument(title, &Elements.createElement);
|
||||
}
|
||||
|
||||
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" },
|
||||
}, .{});
|
||||
}
|
||||
290
src/browser/dom/intersection_observer.zig
Normal file
290
src/browser/dom/intersection_observer.zig
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
389
src/browser/dom/mutation_observer.zig
Normal file
389
src/browser/dom/mutation_observer.zig
Normal file
@@ -0,0 +1,389 @@
|
||||
// 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 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 {
|
||||
cbk: Env.Function,
|
||||
arena: Allocator,
|
||||
|
||||
// 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,
|
||||
.observed = .{},
|
||||
.arena = page.arena,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
|
||||
const options = options_ orelse MutationObserverInit{};
|
||||
|
||||
const observer = try self.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jsCallScopeEnd(self: *MutationObserver) void {
|
||||
const record = self.observed.items;
|
||||
if (record.len == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
defer self.observed.clearRetainingCapacity();
|
||||
|
||||
for (record) |r| {
|
||||
const records = [_]MutationRecord{r.*};
|
||||
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(_: *MutationObserver) !void {
|
||||
// TODO unregister listeners.
|
||||
}
|
||||
|
||||
// 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 MutationObserverInit = struct {
|
||||
childList: bool = false,
|
||||
attributes: bool = false,
|
||||
characterData: bool = false,
|
||||
subtree: bool = false,
|
||||
attributeOldValue: bool = false,
|
||||
characterDataOldValue: bool = false,
|
||||
// TODO
|
||||
// attributeFilter: [][]const u8,
|
||||
|
||||
fn attr(self: MutationObserverInit) bool {
|
||||
return self.attributes or self.attributeOldValue;
|
||||
}
|
||||
|
||||
fn cdata(self: MutationObserverInit) bool {
|
||||
return self.characterData or self.characterDataOldValue;
|
||||
}
|
||||
};
|
||||
|
||||
const Observer = struct {
|
||||
node: *parser.Node,
|
||||
options: MutationObserverInit,
|
||||
|
||||
// record of the mutation, all observed changes in 1 call are batched
|
||||
record: ?MutationRecord = null,
|
||||
|
||||
// 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(o: *const Observer, target: *parser.Node) bool {
|
||||
// mutation on any target is always ok.
|
||||
if (o.options.subtree) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if target equals node, alway ok.
|
||||
if (target == o.node) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no subtree, no same target and no childlist, always noky.
|
||||
if (!o.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(o.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);
|
||||
};
|
||||
|
||||
if (self.appliesTo(node) == false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event_type = blk: {
|
||||
const t = try parser.eventType(event);
|
||||
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
|
||||
};
|
||||
|
||||
const arena = mutation_observer.arena;
|
||||
if (self.record == null) {
|
||||
self.record = .{
|
||||
.target = self.node,
|
||||
.type = event_type.recordType(),
|
||||
};
|
||||
try mutation_observer.observed.append(arena, &self.record.?);
|
||||
}
|
||||
|
||||
var record = &self.record.?;
|
||||
const mutation_event = parser.eventToMutationEvent(event);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
\\ 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";
|
||||
\\ 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";
|
||||
,
|
||||
"2",
|
||||
},
|
||||
}, .{});
|
||||
}
|
||||
138
src/browser/dom/namednodemap.zig
Normal file
138
src/browser/dom/namednodemap.zig
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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/#namednodemap
|
||||
pub const NamedNodeMap = struct {
|
||||
pub const Self = parser.NamedNodeMap;
|
||||
|
||||
pub const Exception = DOMException;
|
||||
pub const Iterator = NamedNodeMapIterator;
|
||||
|
||||
// TODO implement LegacyUnenumerableNamedProperties.
|
||||
// https://webidl.spec.whatwg.org/#LegacyUnenumerableNamedProperties
|
||||
|
||||
pub fn get_length(self: *parser.NamedNodeMap) !u32 {
|
||||
return try parser.namedNodeMapGetLength(self);
|
||||
}
|
||||
|
||||
pub fn _item(self: *parser.NamedNodeMap, index: u32) !?*parser.Attribute {
|
||||
return try parser.namedNodeMapItem(self, index);
|
||||
}
|
||||
|
||||
pub fn _getNamedItem(self: *parser.NamedNodeMap, qname: []const u8) !?*parser.Attribute {
|
||||
return try parser.namedNodeMapGetNamedItem(self, qname);
|
||||
}
|
||||
|
||||
pub fn _getNamedItemNS(
|
||||
self: *parser.NamedNodeMap,
|
||||
namespace: []const u8,
|
||||
localname: []const u8,
|
||||
) !?*parser.Attribute {
|
||||
return try parser.namedNodeMapGetNamedItemNS(self, namespace, localname);
|
||||
}
|
||||
|
||||
pub fn _setNamedItem(self: *parser.NamedNodeMap, attr: *parser.Attribute) !?*parser.Attribute {
|
||||
return try parser.namedNodeMapSetNamedItem(self, attr);
|
||||
}
|
||||
|
||||
pub fn _setNamedItemNS(self: *parser.NamedNodeMap, attr: *parser.Attribute) !?*parser.Attribute {
|
||||
return try parser.namedNodeMapSetNamedItemNS(self, attr);
|
||||
}
|
||||
|
||||
pub fn _removeNamedItem(self: *parser.NamedNodeMap, qname: []const u8) !*parser.Attribute {
|
||||
return try parser.namedNodeMapRemoveNamedItem(self, qname);
|
||||
}
|
||||
|
||||
pub fn _removeNamedItemNS(
|
||||
self: *parser.NamedNodeMap,
|
||||
namespace: []const u8,
|
||||
localname: []const u8,
|
||||
) !*parser.Attribute {
|
||||
return try parser.namedNodeMapRemoveNamedItemNS(self, namespace, localname);
|
||||
}
|
||||
|
||||
pub fn indexed_get(self: *parser.NamedNodeMap, index: u32, has_value: *bool) !*parser.Attribute {
|
||||
return (try _item(self, index)) orelse {
|
||||
has_value.* = false;
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn named_get(self: *parser.NamedNodeMap, name: []const u8, has_value: *bool) !*parser.Attribute {
|
||||
return (try _getNamedItem(self, name)) orelse {
|
||||
has_value.* = false;
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *parser.NamedNodeMap) NamedNodeMapIterator {
|
||||
return .{ .map = self };
|
||||
}
|
||||
};
|
||||
|
||||
pub const NamedNodeMapIterator = struct {
|
||||
index: u32 = 0,
|
||||
map: *parser.NamedNodeMap,
|
||||
|
||||
pub const Return = struct {
|
||||
done: bool,
|
||||
value: ?*parser.Attribute,
|
||||
};
|
||||
|
||||
pub fn _next(self: *NamedNodeMapIterator) !Return {
|
||||
const e = try NamedNodeMap._item(self.map, self.index);
|
||||
if (e == null) {
|
||||
return .{
|
||||
.value = null,
|
||||
.done = true,
|
||||
};
|
||||
}
|
||||
|
||||
self.index += 1;
|
||||
return .{
|
||||
.value = e,
|
||||
.done = false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.NamedNodeMap" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let a = document.getElementById('content').attributes", "undefined" },
|
||||
.{ "a.length", "1" },
|
||||
.{ "a.item(0)", "[object Attr]" },
|
||||
.{ "a.item(1)", "null" },
|
||||
.{ "a.getNamedItem('id')", "[object Attr]" },
|
||||
.{ "a.getNamedItem('foo')", "null" },
|
||||
.{ "a.setNamedItem(a.getNamedItem('id'))", "[object Attr]" },
|
||||
.{ "a['id'].name", "id" },
|
||||
.{ "a['id'].value", "content" },
|
||||
.{ "a['other']", "undefined" },
|
||||
}, .{});
|
||||
}
|
||||
722
src/browser/dom/node.zig
Normal file
722
src/browser/dom/node.zig
Normal file
@@ -0,0 +1,722 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
52
src/browser/dom/node_filter.zig
Normal file
52
src/browser/dom/node_filter.zig
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const NodeFilter = struct {
|
||||
pub const _FILTER_ACCEPT: u16 = 1;
|
||||
pub const _FILTER_REJECT: u16 = 2;
|
||||
pub const _FILTER_SKIP: u16 = 3;
|
||||
pub const _SHOW_ALL: u32 = std.math.maxInt(u32);
|
||||
pub const _SHOW_ELEMENT: u32 = 0b1;
|
||||
pub const _SHOW_ATTRIBUTE: u32 = 0b10;
|
||||
pub const _SHOW_TEXT: u32 = 0b100;
|
||||
pub const _SHOW_CDATA_SECTION: u32 = 0b1000;
|
||||
pub const _SHOW_ENTITY_REFERENCE: u32 = 0b10000;
|
||||
pub const _SHOW_ENTITY: u32 = 0b100000;
|
||||
pub const _SHOW_PROCESSING_INSTRUCTION: u32 = 0b1000000;
|
||||
pub const _SHOW_COMMENT: u32 = 0b10000000;
|
||||
pub const _SHOW_DOCUMENT: u32 = 0b100000000;
|
||||
pub const _SHOW_DOCUMENT_TYPE: u32 = 0b1000000000;
|
||||
pub const _SHOW_DOCUMENT_FRAGMENT: u32 = 0b10000000000;
|
||||
pub const _SHOW_NOTATION: u32 = 0b100000000000;
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.NodeFilter" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "NodeFilter.FILTER_ACCEPT", "1" },
|
||||
.{ "NodeFilter.FILTER_REJECT", "2" },
|
||||
.{ "NodeFilter.FILTER_SKIP", "3" },
|
||||
.{ "NodeFilter.SHOW_ALL", "4294967295" },
|
||||
.{ "NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT", "129" },
|
||||
}, .{});
|
||||
}
|
||||
198
src/browser/dom/nodelist.zig
Normal file
198
src/browser/dom/nodelist.zig
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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 JsThis = @import("../env.zig").JsThis;
|
||||
const Function = @import("../env.zig").Function;
|
||||
|
||||
const NodeUnion = @import("node.zig").Union;
|
||||
const Node = @import("node.zig").Node;
|
||||
|
||||
const U32Iterator = @import("../iterator/iterator.zig").U32Iterator;
|
||||
|
||||
const DOMException = @import("exceptions.zig").DOMException;
|
||||
|
||||
pub const Interfaces = .{
|
||||
NodeListIterator,
|
||||
NodeList,
|
||||
};
|
||||
|
||||
pub const NodeListIterator = struct {
|
||||
coll: *NodeList,
|
||||
index: u32 = 0,
|
||||
|
||||
pub const Return = struct {
|
||||
value: ?NodeUnion,
|
||||
done: bool,
|
||||
};
|
||||
|
||||
pub fn _next(self: *NodeListIterator) !Return {
|
||||
const e = try self.coll._item(self.index);
|
||||
if (e == null) {
|
||||
return Return{
|
||||
.value = null,
|
||||
.done = true,
|
||||
};
|
||||
}
|
||||
|
||||
self.index += 1;
|
||||
return Return{
|
||||
.value = e,
|
||||
.done = false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const NodeListEntriesIterator = struct {
|
||||
coll: *NodeList,
|
||||
index: u32 = 0,
|
||||
|
||||
pub const Return = struct {
|
||||
value: ?NodeUnion,
|
||||
done: bool,
|
||||
};
|
||||
|
||||
pub fn _next(self: *NodeListEntriesIterator) !Return {
|
||||
const e = try self.coll._item(self.index);
|
||||
if (e == null) {
|
||||
return Return{
|
||||
.value = null,
|
||||
.done = true,
|
||||
};
|
||||
}
|
||||
|
||||
self.index += 1;
|
||||
return Return{
|
||||
.value = e,
|
||||
.done = false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Nodelist is implemented in pure Zig b/c libdom's NodeList doesn't allow to
|
||||
// append nodes.
|
||||
// WEB IDL https://dom.spec.whatwg.org/#nodelist
|
||||
//
|
||||
// TODO: a Nodelist can be either static or live. But the current
|
||||
// implementation allows only static nodelist.
|
||||
// see https://dom.spec.whatwg.org/#old-style-collections
|
||||
pub const NodeList = struct {
|
||||
pub const Exception = DOMException;
|
||||
const NodesArrayList = std.ArrayListUnmanaged(*parser.Node);
|
||||
|
||||
nodes: NodesArrayList = .{},
|
||||
|
||||
pub fn deinit(self: *NodeList, alloc: std.mem.Allocator) void {
|
||||
// TODO unref all nodes
|
||||
self.nodes.deinit(alloc);
|
||||
}
|
||||
|
||||
pub fn append(self: *NodeList, alloc: std.mem.Allocator, node: *parser.Node) !void {
|
||||
try self.nodes.append(alloc, node);
|
||||
}
|
||||
|
||||
pub fn get_length(self: *NodeList) u32 {
|
||||
return @intCast(self.nodes.items.len);
|
||||
}
|
||||
|
||||
pub fn _item(self: *const NodeList, index: u32) !?NodeUnion {
|
||||
if (index >= self.nodes.items.len) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const n = self.nodes.items[index];
|
||||
return try Node.toInterface(n);
|
||||
}
|
||||
|
||||
// This code works, but it's _MUCH_ slower than using postAttach. The benefit
|
||||
// of this version, is that it's "live"..but we're talking many orders of
|
||||
// magnitude slower.
|
||||
//
|
||||
// You can test it by commenting out `postAttach`, uncommenting this and
|
||||
// running:
|
||||
// zig build wpt -- tests/wpt/dom/nodes/NodeList-static-length-getter-tampered-indexOf-1.html
|
||||
//
|
||||
// I think this _is_ the right way to do it, but I must be doing something
|
||||
// wrong to make it so slow.
|
||||
// pub fn indexed_get(self: *const NodeList, index: u32, has_value: *bool) !?NodeUnion {
|
||||
// return (try self._item(index)) orelse {
|
||||
// has_value.* = false;
|
||||
// return null;
|
||||
// };
|
||||
// }
|
||||
|
||||
pub fn _forEach(self: *NodeList, cbk: Function) !void { // TODO handle thisArg
|
||||
for (self.nodes.items, 0..) |n, i| {
|
||||
const ii: u32 = @intCast(i);
|
||||
var result: Function.Result = undefined;
|
||||
cbk.tryCall(void, .{ n, ii, self }, &result) catch {
|
||||
log.debug(.user_script, "forEach callback", .{ .err = result.exception, .stack = result.stack });
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn _keys(self: *NodeList) U32Iterator {
|
||||
return .{
|
||||
.length = self.get_length(),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _values(self: *NodeList) NodeListIterator {
|
||||
return .{
|
||||
.coll = self,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn _symbol_iterator(self: *NodeList) NodeListIterator {
|
||||
return self._values();
|
||||
}
|
||||
|
||||
// TODO entries() https://developer.mozilla.org/en-US/docs/Web/API/NodeList/entries
|
||||
pub fn postAttach(self: *NodeList, js_this: JsThis) !void {
|
||||
const len = self.get_length();
|
||||
for (0..len) |i| {
|
||||
const node = try self._item(@intCast(i)) orelse unreachable;
|
||||
try js_this.setIndex(@intCast(i), node, .{});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.NodeList" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let list = document.getElementById('content').childNodes", "undefined" },
|
||||
.{ "list.length", "9" },
|
||||
.{ "list[0].__proto__.constructor.name", "Text" },
|
||||
.{
|
||||
\\ let i = 0;
|
||||
\\ list.forEach(function (n, idx) {
|
||||
\\ i += idx;
|
||||
\\ });
|
||||
\\ i;
|
||||
,
|
||||
"36",
|
||||
},
|
||||
}, .{});
|
||||
}
|
||||
116
src/browser/dom/processing_instruction.zig
Normal file
116
src/browser/dom/processing_instruction.zig
Normal file
@@ -0,0 +1,116 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
86
src/browser/dom/text.zig
Normal file
86
src/browser/dom/text.zig
Normal file
@@ -0,0 +1,86 @@
|
||||
// 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 Page = @import("../page.zig").Page;
|
||||
|
||||
const CharacterData = @import("character_data.zig").CharacterData;
|
||||
const CDATASection = @import("cdata_section.zig").CDATASection;
|
||||
|
||||
// Text interfaces
|
||||
pub const Interfaces = .{
|
||||
CDATASection,
|
||||
};
|
||||
|
||||
pub const Text = struct {
|
||||
pub const Self = parser.Text;
|
||||
pub const prototype = *CharacterData;
|
||||
pub const subtype = .node;
|
||||
|
||||
pub fn constructor(data: ?[]const u8, page: *const Page) !*parser.Text {
|
||||
return parser.documentCreateTextNode(
|
||||
parser.documentHTMLToDocument(page.window.document),
|
||||
data orelse "",
|
||||
);
|
||||
}
|
||||
|
||||
// JS funcs
|
||||
// --------
|
||||
|
||||
// Read attributes
|
||||
|
||||
pub fn get_wholeText(self: *parser.Text) ![]const u8 {
|
||||
return try parser.textWholdeText(self);
|
||||
}
|
||||
|
||||
// JS methods
|
||||
// ----------
|
||||
|
||||
pub fn _splitText(self: *parser.Text, offset: u32) !*parser.Text {
|
||||
return try parser.textSplitText(self, offset);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.DOM.Text" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let t = new Text('foo')", "undefined" },
|
||||
.{ "t.data", "foo" },
|
||||
|
||||
.{ "let emptyt = new Text()", "undefined" },
|
||||
.{ "emptyt.data", "" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "let text = document.getElementById('link').firstChild", "undefined" },
|
||||
.{ "text.wholeText === 'OK'", "true" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "text.data = 'OK modified'", "OK modified" },
|
||||
.{ "let split = text.splitText('OK'.length)", "undefined" },
|
||||
.{ "split.data === ' modified'", "true" },
|
||||
.{ "text.data === 'OK'", "true" },
|
||||
}, .{});
|
||||
}
|
||||
245
src/browser/dom/token_list.zig
Normal file
245
src/browser/dom/token_list.zig
Normal file
@@ -0,0 +1,245 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
293
src/browser/dom/tree_walker.zig
Normal file
293
src/browser/dom/tree_walker.zig
Normal file
@@ -0,0 +1,293 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const parser = @import("../netsurf.zig");
|
||||
|
||||
const NodeFilter = @import("node_filter.zig").NodeFilter;
|
||||
const Env = @import("../env.zig").Env;
|
||||
const 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;
|
||||
}
|
||||
};
|
||||
102
src/browser/dom/walker.zig
Normal file
102
src/browser/dom/walker.zig
Normal file
@@ -0,0 +1,102 @@
|
||||
// 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");
|
||||
|
||||
pub const Walker = union(enum) {
|
||||
walkerDepthFirst: WalkerDepthFirst,
|
||||
walkerChildren: WalkerChildren,
|
||||
walkerNone: WalkerNone,
|
||||
|
||||
pub fn get_next(self: Walker, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
|
||||
switch (self) {
|
||||
inline else => |case| return case.get_next(root, cur),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// WalkerDepthFirst iterates over the DOM tree to return the next following
|
||||
// node or null at the end.
|
||||
//
|
||||
// This implementation is a zig version of Netsurf code.
|
||||
// http://source.netsurf-browser.org/libdom.git/tree/src/html/html_collection.c#n177
|
||||
//
|
||||
// The iteration is a depth first as required by the specification.
|
||||
// https://dom.spec.whatwg.org/#htmlcollection
|
||||
// https://dom.spec.whatwg.org/#concept-tree-order
|
||||
pub const WalkerDepthFirst = struct {
|
||||
pub fn get_next(_: WalkerDepthFirst, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
|
||||
var n = cur orelse root;
|
||||
|
||||
// TODO deinit next
|
||||
if (try parser.nodeFirstChild(n)) |next| {
|
||||
return next;
|
||||
}
|
||||
|
||||
// TODO deinit next
|
||||
if (try parser.nodeNextSibling(n)) |next| {
|
||||
return next;
|
||||
}
|
||||
|
||||
// TODO deinit parent
|
||||
// Back to the parent of cur.
|
||||
// If cur has no parent, then the iteration is over.
|
||||
var parent = try parser.nodeParentNode(n) orelse return null;
|
||||
|
||||
// TODO deinit lastchild
|
||||
var lastchild = try parser.nodeLastChild(parent);
|
||||
while (n != root and n == lastchild) {
|
||||
n = parent;
|
||||
|
||||
// TODO deinit parent
|
||||
// Back to the prev's parent.
|
||||
// If prev has no parent, then the loop must stop.
|
||||
parent = try parser.nodeParentNode(n) orelse break;
|
||||
|
||||
// TODO deinit lastchild
|
||||
lastchild = try parser.nodeLastChild(parent);
|
||||
}
|
||||
|
||||
if (n == root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return try parser.nodeNextSibling(n);
|
||||
}
|
||||
};
|
||||
|
||||
// WalkerChildren iterates over the root's children only.
|
||||
pub const WalkerChildren = struct {
|
||||
pub fn get_next(_: WalkerChildren, root: *parser.Node, cur: ?*parser.Node) !?*parser.Node {
|
||||
// On walk start, we return the first root's child.
|
||||
if (cur == null) return try parser.nodeFirstChild(root);
|
||||
|
||||
// If cur is root, then return null.
|
||||
// This is a special case, if the root is included in the walk, we
|
||||
// don't want to go further to find children.
|
||||
if (root == cur.?) return null;
|
||||
|
||||
return try parser.nodeNextSibling(cur.?);
|
||||
}
|
||||
};
|
||||
|
||||
pub const WalkerNone = struct {
|
||||
pub fn get_next(_: WalkerNone, _: *parser.Node, _: ?*parser.Node) !?*parser.Node {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -17,344 +17,219 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const Page = @import("Page.zig");
|
||||
const Node = @import("webapi/Node.zig");
|
||||
const Slot = @import("webapi/element/html/Slot.zig");
|
||||
const IFrame = @import("webapi/element/html/IFrame.zig");
|
||||
|
||||
const IS_DEBUG = @import("builtin").mode == .Debug;
|
||||
const parser = @import("netsurf.zig");
|
||||
const Walker = @import("dom/walker.zig").WalkerChildren;
|
||||
|
||||
pub const Opts = struct {
|
||||
with_base: bool = false,
|
||||
with_frames: bool = false,
|
||||
strip: Opts.Strip = .{},
|
||||
shadow: Opts.Shadow = .rendered,
|
||||
|
||||
pub const Strip = struct {
|
||||
js: bool = false,
|
||||
ui: bool = false,
|
||||
css: bool = false,
|
||||
};
|
||||
|
||||
pub const Shadow = enum {
|
||||
// Skip shadow DOM entirely (innerHTML/outerHTML)
|
||||
skip,
|
||||
|
||||
// Dump everyhting (like "view source")
|
||||
complete,
|
||||
|
||||
// Resolve slot elements (like what actually gets rendered)
|
||||
rendered,
|
||||
};
|
||||
};
|
||||
|
||||
pub fn root(doc: *Node.Document, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||
if (doc.is(Node.Document.HTMLDocument)) |html_doc| {
|
||||
blk: {
|
||||
// Ideally we just render the doctype which is part of the document
|
||||
if (doc.asNode().firstChild()) |first| {
|
||||
if (first._type == .document_type) {
|
||||
break :blk;
|
||||
}
|
||||
}
|
||||
// But if the doc has no child, or the first child isn't a doctype
|
||||
// well force it.
|
||||
try writer.writeAll("<!DOCTYPE html>");
|
||||
// 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 writer.writeAll("\n");
|
||||
}
|
||||
|
||||
if (opts.with_base) {
|
||||
const parent = if (html_doc.getHead()) |head| head.asNode() else doc.asNode();
|
||||
const base = try doc.createElement("base", null, page);
|
||||
try base.setAttributeSafe(comptime .wrap("base"), .wrap(page.base()), page);
|
||||
_ = try parent.insertBefore(base.asNode(), parent.firstChild(), page);
|
||||
}
|
||||
}
|
||||
|
||||
return deep(doc.asNode(), opts, writer, page);
|
||||
}
|
||||
|
||||
pub fn deep(node: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
||||
return _deep(node, opts, false, writer, page);
|
||||
}
|
||||
|
||||
fn _deep(node: *Node, opts: Opts, comptime force_slot: bool, writer: *std.Io.Writer, page: *Page) error{WriteFailed}!void {
|
||||
switch (node._type) {
|
||||
.cdata => |cd| {
|
||||
if (node.is(Node.CData.Comment)) |_| {
|
||||
try writer.writeAll("<!--");
|
||||
try writer.writeAll(cd.getData().str());
|
||||
try writer.writeAll("-->");
|
||||
} else if (node.is(Node.CData.ProcessingInstruction)) |pi| {
|
||||
try writer.writeAll("<?");
|
||||
try writer.writeAll(pi._target);
|
||||
try writer.writeAll(" ");
|
||||
try writer.writeAll(cd.getData().str());
|
||||
try writer.writeAll("?>");
|
||||
} else {
|
||||
if (shouldEscapeText(node._parent)) {
|
||||
try writeEscapedText(cd.getData().str(), writer);
|
||||
} else {
|
||||
try writer.writeAll(cd.getData().str());
|
||||
}
|
||||
}
|
||||
},
|
||||
.element => |el| {
|
||||
if (shouldStripElement(el, opts)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When opts.shadow == .rendered, we normally skip any element with
|
||||
// a slot attribute. Only the "active" element will get rendered into
|
||||
// the <slot name="X">. However, the `deep` function is itself used
|
||||
// to render that "active" content, so when we're trying to render
|
||||
// it, we don't want to skip it.
|
||||
if ((comptime force_slot == false) and opts.shadow == .rendered) {
|
||||
if (el.getAttributeSafe(comptime .wrap("slot"))) |_| {
|
||||
// Skip - will be rendered by the Slot if it's the active container
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try el.format(writer);
|
||||
|
||||
if (opts.shadow == .rendered) {
|
||||
if (el.is(Slot)) |slot| {
|
||||
try dumpSlotContent(slot, opts, writer, page);
|
||||
return writer.writeAll("</slot>");
|
||||
}
|
||||
}
|
||||
if (opts.shadow != .skip) {
|
||||
if (page._element_shadow_roots.get(el)) |shadow| {
|
||||
try children(shadow.asNode(), opts, writer, page);
|
||||
// In rendered mode, light DOM is only shown through slots, not directly
|
||||
if (opts.shadow == .rendered) {
|
||||
// Skip rendering light DOM children
|
||||
if (!isVoidElement(el)) {
|
||||
try writer.writeAll("</");
|
||||
try writer.writeAll(el.getTagNameDump());
|
||||
try writer.writeByte('>');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.with_frames and el.is(IFrame) != null) {
|
||||
const frame = el.as(IFrame);
|
||||
if (frame.getContentDocument()) |doc| {
|
||||
// A frame's document should always ahave a page, but
|
||||
// I'm not willing to crash a release build on that assertion.
|
||||
if (comptime IS_DEBUG) {
|
||||
std.debug.assert(doc._page != null);
|
||||
}
|
||||
if (doc._page) |frame_page| {
|
||||
try writer.writeByte('\n');
|
||||
root(doc, opts, writer, frame_page) catch return error.WriteFailed;
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try children(node, opts, writer, page);
|
||||
}
|
||||
|
||||
if (!isVoidElement(el)) {
|
||||
try writer.writeAll("</");
|
||||
try writer.writeAll(el.getTagNameDump());
|
||||
try writer.writeByte('>');
|
||||
}
|
||||
},
|
||||
.document => try children(node, opts, writer, page),
|
||||
.document_type => |dt| {
|
||||
// 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(dt.getName());
|
||||
try writer.writeAll(try parser.documentTypeGetName(doc_type));
|
||||
|
||||
const public_id = dt.getPublicId();
|
||||
const system_id = dt.getSystemId();
|
||||
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 writeEscapedText(public_id, writer);
|
||||
try writeEscapedAttributeValue(writer, public_id);
|
||||
try writer.writeAll("\" \"");
|
||||
try writeEscapedText(system_id, writer);
|
||||
try writer.writeByte('"');
|
||||
try writeEscapedAttributeValue(writer, system_id);
|
||||
try writer.writeAll("\"");
|
||||
} else if (public_id.len != 0) {
|
||||
try writer.writeAll(" PUBLIC \"");
|
||||
try writeEscapedText(public_id, writer);
|
||||
try writer.writeByte('"');
|
||||
try writeEscapedAttributeValue(writer, public_id);
|
||||
try writer.writeAll("\"");
|
||||
} else if (system_id.len != 0) {
|
||||
try writer.writeAll(" SYSTEM \"");
|
||||
try writeEscapedText(system_id, writer);
|
||||
try writer.writeByte('"');
|
||||
try writeEscapedAttributeValue(writer, system_id);
|
||||
try writer.writeAll("\"");
|
||||
}
|
||||
try writer.writeAll(">\n");
|
||||
},
|
||||
.document_fragment => try children(node, opts, writer, page),
|
||||
.attribute => {
|
||||
// Not called normally, but can be called via XMLSerializer.serializeToString
|
||||
// in which case it should return an empty string
|
||||
// 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;
|
||||
|
||||
// write the children
|
||||
// TODO avoid recursion
|
||||
try writeChildren(node, writer);
|
||||
|
||||
// close the tag
|
||||
try writer.writeAll("</");
|
||||
try writer.writeAll(tag);
|
||||
try writer.writeAll(">");
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn children(parent: *Node, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||
var it = parent.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try deep(child, opts, writer, page);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toJSON(node: *Node, writer: *std.json.Stringify) !void {
|
||||
try writer.beginObject();
|
||||
|
||||
try writer.objectField("type");
|
||||
switch (node.type) {
|
||||
.cdata => {
|
||||
try writer.write("cdata");
|
||||
.text => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writeEscapedTextNode(writer, v);
|
||||
},
|
||||
.document => {
|
||||
try writer.write("document");
|
||||
.cdata_section => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writer.writeAll("<![CDATA[");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("]]>");
|
||||
},
|
||||
.document_type => {
|
||||
try writer.write("document_type");
|
||||
.comment => {
|
||||
const v = try parser.nodeValue(node) orelse return;
|
||||
try writer.writeAll("<!--");
|
||||
try writer.writeAll(v);
|
||||
try writer.writeAll("-->");
|
||||
},
|
||||
.element => |*el| {
|
||||
try writer.write("element");
|
||||
try writer.objectField("tag");
|
||||
try writer.write(el.tagName());
|
||||
|
||||
try writer.objectField("attributes");
|
||||
try writer.beginObject();
|
||||
var it = el.attributeIterator();
|
||||
while (it.next()) |attr| {
|
||||
try writer.objectField(attr.name);
|
||||
try writer.write(attr.value);
|
||||
}
|
||||
try writer.endObject();
|
||||
},
|
||||
}
|
||||
|
||||
try writer.objectField("children");
|
||||
try writer.beginArray();
|
||||
var it = node.childrenIterator();
|
||||
while (it.next()) |child| {
|
||||
try toJSON(child, writer);
|
||||
}
|
||||
try writer.endArray();
|
||||
try writer.endObject();
|
||||
}
|
||||
|
||||
fn dumpSlotContent(slot: *Slot, opts: Opts, writer: *std.Io.Writer, page: *Page) !void {
|
||||
const assigned = slot.assignedNodes(null, page) catch return;
|
||||
|
||||
if (assigned.len > 0) {
|
||||
for (assigned) |assigned_node| {
|
||||
try _deep(assigned_node, opts, true, writer, page);
|
||||
}
|
||||
} else {
|
||||
try children(slot.asNode(), opts, writer, page);
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn isVoidElement(el: *const Node.Element) bool {
|
||||
return switch (el._type) {
|
||||
.html => |html| switch (html._type) {
|
||||
.br, .hr, .img, .input, .link, .meta => true,
|
||||
// writer must be a std.io.Writer
|
||||
pub fn writeChildren(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);
|
||||
}
|
||||
}
|
||||
|
||||
// area, base, br, col, embed, hr, img, input, link, meta, source, track, wbr
|
||||
// https://html.spec.whatwg.org/#void-elements
|
||||
fn isVoid(elem: *parser.Element) !bool {
|
||||
const tag = try parser.elementHTMLGetTagType(@as(*parser.ElementHTML, @ptrCast(elem)));
|
||||
return switch (tag) {
|
||||
.area, .base, .br, .col, .embed, .hr, .img, .input, .link => true,
|
||||
.meta, .source, .track, .wbr => true,
|
||||
else => false,
|
||||
},
|
||||
.svg => false,
|
||||
};
|
||||
}
|
||||
|
||||
fn shouldStripElement(el: *const Node.Element, opts: Opts) bool {
|
||||
const tag_name = el.getTagNameDump();
|
||||
|
||||
if (opts.strip.js) {
|
||||
if (std.mem.eql(u8, tag_name, "script")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "noscript")) return true;
|
||||
|
||||
if (std.mem.eql(u8, tag_name, "link")) {
|
||||
if (el.getAttributeSafe(comptime .wrap("as"))) |as| {
|
||||
if (std.mem.eql(u8, as, "script")) return true;
|
||||
}
|
||||
if (el.getAttributeSafe(comptime .wrap("rel"))) |rel| {
|
||||
if (std.mem.eql(u8, rel, "modulepreload") or std.mem.eql(u8, rel, "preload")) {
|
||||
if (el.getAttributeSafe(comptime .wrap("as"))) |as| {
|
||||
if (std.mem.eql(u8, as, "script")) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.strip.css or opts.strip.ui) {
|
||||
if (std.mem.eql(u8, tag_name, "style")) return true;
|
||||
|
||||
if (std.mem.eql(u8, tag_name, "link")) {
|
||||
if (el.getAttributeSafe(comptime .wrap("rel"))) |rel| {
|
||||
if (std.mem.eql(u8, rel, "stylesheet")) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.strip.ui) {
|
||||
if (std.mem.eql(u8, tag_name, "img")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "picture")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "video")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "audio")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "svg")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "canvas")) return true;
|
||||
if (std.mem.eql(u8, tag_name, "iframe")) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fn shouldEscapeText(node_: ?*Node) bool {
|
||||
const node = node_ orelse return true;
|
||||
if (node.is(Node.Element.Html.Script) != null) {
|
||||
return false;
|
||||
}
|
||||
// When scripting is enabled, <noscript> is a raw text element per the HTML spec
|
||||
// (https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments).
|
||||
// Its text content must not be HTML-escaped during serialization.
|
||||
if (node.is(Node.Element.Html.Generic)) |generic| {
|
||||
if (generic._tag == .noscript) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
fn writeEscapedText(text: []const u8, writer: *std.Io.Writer) !void {
|
||||
// Fast path: if no special characters, write directly
|
||||
const first_special = std.mem.indexOfAnyPos(u8, text, 0, &.{ '&', '<', '>', 194 }) orelse {
|
||||
return writer.writeAll(text);
|
||||
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(text[0..first_special]);
|
||||
var remaining = try writeEscapedByte(text, first_special, writer);
|
||||
|
||||
while (std.mem.indexOfAnyPos(u8, remaining, 0, &.{ '&', '<', '>', 194 })) |offset| {
|
||||
try writer.writeAll(remaining[0..offset]);
|
||||
remaining = try writeEscapedByte(remaining, offset, writer);
|
||||
}
|
||||
|
||||
if (remaining.len > 0) {
|
||||
try writer.writeAll(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeEscapedByte(input: []const u8, index: usize, writer: *std.Io.Writer) ![]const u8 {
|
||||
switch (input[index]) {
|
||||
try writer.writeAll(v[0..index]);
|
||||
switch (v[index]) {
|
||||
'&' => try writer.writeAll("&"),
|
||||
'<' => try writer.writeAll("<"),
|
||||
'>' => try writer.writeAll(">"),
|
||||
194 => {
|
||||
// non breaking space
|
||||
if (input.len > index + 1 and input[index + 1] == 160) {
|
||||
try writer.writeAll(" ");
|
||||
return input[index + 2 ..];
|
||||
}
|
||||
try writer.writeByte(194);
|
||||
},
|
||||
else => unreachable,
|
||||
}
|
||||
return input[index + 1 ..];
|
||||
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(testing.allocator);
|
||||
defer parser.deinit();
|
||||
|
||||
try testWriteHTML(
|
||||
"<div id=\"content\">Over 9000!</div>",
|
||||
"<div id=\"content\">Over 9000!</div>",
|
||||
);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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 Elements = @import("html/elements.zig");
|
||||
const doc_html = try parser.documentHTMLParseFromStr(src, &Elements.createElement);
|
||||
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);
|
||||
}
|
||||
|
||||
64
src/browser/encoding/text_encoder.zig
Normal file
64
src/browser/encoding/text_encoder.zig
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const Env = @import("../env.zig").Env;
|
||||
|
||||
pub const Interfaces = .{
|
||||
TextEncoder,
|
||||
};
|
||||
|
||||
// https://encoding.spec.whatwg.org/#interface-textencoder
|
||||
pub const TextEncoder = struct {
|
||||
pub fn constructor() !TextEncoder {
|
||||
return .{};
|
||||
}
|
||||
|
||||
pub fn get_encoding(_: *const TextEncoder) []const u8 {
|
||||
return "utf-8";
|
||||
}
|
||||
|
||||
pub fn _encode(_: *const TextEncoder, v: []const u8) !Env.TypedArray(u8) {
|
||||
// Ensure the input is a valid utf-8
|
||||
// It seems chrome accepts invalid utf-8 sequence.
|
||||
//
|
||||
if (!std.unicode.utf8ValidateSlice(v)) {
|
||||
return error.InvalidUtf8;
|
||||
}
|
||||
|
||||
return .{ .values = v };
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.Encoding.TextEncoder" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "var encoder = new TextEncoder();", "undefined" },
|
||||
.{ "encoder.encoding;", "utf-8" },
|
||||
.{ "encoder.encode('€');", "226,130,172" },
|
||||
|
||||
// Invalid utf-8 sequence.
|
||||
// Result with chrome:
|
||||
// .{ "encoder.encode(new Uint8Array([0xE2,0x28,0xA1]))", "50,50,54,44,52,48,44,49,54,49" },
|
||||
.{ "try {encoder.encode(new Uint8Array([0xE2,0x28,0xA1])) } catch (e) { e };", "Error: InvalidUtf8" },
|
||||
}, .{});
|
||||
}
|
||||
43
src/browser/env.zig
Normal file
43
src/browser/env.zig
Normal file
@@ -0,0 +1,43 @@
|
||||
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("cssom/css_style_declaration.zig").Interfaces,
|
||||
@import("dom/dom.zig").Interfaces,
|
||||
@import("encoding/text_encoder.zig").Interfaces,
|
||||
@import("events/event.zig").Interfaces,
|
||||
@import("html/html.zig").Interfaces,
|
||||
@import("iterator/iterator.zig").Interfaces,
|
||||
@import("storage/storage.zig").Interfaces,
|
||||
@import("url/url.zig").Interfaces,
|
||||
@import("xhr/xhr.zig").Interfaces,
|
||||
@import("xhr/form_data.zig").Interfaces,
|
||||
@import("xmlserializer/xmlserializer.zig").Interfaces,
|
||||
});
|
||||
};
|
||||
|
||||
pub const JsThis = Env.JsThis;
|
||||
pub const JsObject = Env.JsObject;
|
||||
pub const Function = Env.Function;
|
||||
pub const Env = js.Env(*Page, WebApis);
|
||||
pub const Global = @import("html/window.zig").Window;
|
||||
80
src/browser/events/custom_event.zig
Normal file
80
src/browser/events/custom_event.zig
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
374
src/browser/events/event.zig
Normal file
374
src/browser/events/event.zig
Normal file
@@ -0,0 +1,374 @@
|
||||
// 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 CustomEvent = @import("custom_event.zig").CustomEvent;
|
||||
const ProgressEvent = @import("../xhr/progress_event.zig").ProgressEvent;
|
||||
const MouseEvent = @import("mouse_event.zig").MouseEvent;
|
||||
|
||||
// Event interfaces
|
||||
pub const Interfaces = .{
|
||||
Event,
|
||||
CustomEvent,
|
||||
ProgressEvent,
|
||||
MouseEvent,
|
||||
};
|
||||
|
||||
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 => .{ .Event = evt },
|
||||
.custom_event => .{ .CustomEvent = @as(*CustomEvent, @ptrCast(evt)).* },
|
||||
.progress_event => .{ .ProgressEvent = @as(*ProgressEvent, @ptrCast(evt)).* },
|
||||
.mouse_event => .{ .MouseEvent = @as(*parser.MouseEvent, @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(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(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: ?bool, // 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;
|
||||
if (opts_) |opts| {
|
||||
switch (opts) {
|
||||
.capture => |c| capture = c,
|
||||
.flags => |f| {
|
||||
// Done this way so that, for common cases that _only_ set
|
||||
// capture, i.e. {captrue: true}, it works.
|
||||
// But for any case that sets any of the other flags, we
|
||||
// error. If we don't error, this function call would succeed
|
||||
// but the behavior might be wrong. At this point, it's
|
||||
// better to be explicit and error.
|
||||
if (f.signal orelse false) return error.NotImplemented;
|
||||
once = f.once orelse false;
|
||||
capture = f.capture orelse false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const callback = (try listener.callback(target)) orelse return null;
|
||||
|
||||
// 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 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" },
|
||||
}, .{});
|
||||
}
|
||||
140
src/browser/events/mouse_event.zig
Normal file
140
src/browser/events/mouse_event.zig
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
396
src/browser/html/document.zig
Normal file
396
src/browser/html/document.zig
Normal file
@@ -0,0 +1,396 @@
|
||||
// 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) ![]const u8 {
|
||||
return try parser.documentHTMLGetDomain(self);
|
||||
}
|
||||
|
||||
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 });
|
||||
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();
|
||||
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.
|
||||
pub fn _elementFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) !?ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = page.renderer.getElementAtPosition(ix, iy) orelse return null;
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
return try Element.toInterface(element);
|
||||
}
|
||||
|
||||
// Returns an array of all elements at the specified coordinates (relative to the viewport). The elements are ordered from the topmost to the bottommost box of the viewport.
|
||||
pub fn _elementsFromPoint(_: *parser.DocumentHTML, x: f32, y: f32, page: *Page) ![]ElementUnion {
|
||||
const ix: i32 = @intFromFloat(@floor(x));
|
||||
const iy: i32 = @intFromFloat(@floor(y));
|
||||
const element = page.renderer.getElementAtPosition(ix, iy) orelse return &.{};
|
||||
// TODO if pointer-events set to none the underlying element should be returned (parser.documentGetDocumentElement(self.document);?)
|
||||
|
||||
var list: std.ArrayListUnmanaged(ElementUnion) = .empty;
|
||||
try list.ensureTotalCapacity(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", "" },
|
||||
.{ "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" },
|
||||
}, .{});
|
||||
|
||||
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" },
|
||||
}, .{});
|
||||
}
|
||||
1500
src/browser/html/elements.zig
Normal file
1500
src/browser/html/elements.zig
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
// Copyright (C) 2023-2024 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
@@ -15,21 +15,24 @@
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
const js = @import("js.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const v8 = js.v8;
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Page = @import("../page.zig").Page;
|
||||
const HTMLElement = @import("elements.zig").HTMLElement;
|
||||
const FormData = @import("../xhr/form_data.zig").FormData;
|
||||
|
||||
const Integer = @This();
|
||||
pub const HTMLFormElement = struct {
|
||||
pub const Self = parser.Form;
|
||||
pub const prototype = *HTMLElement;
|
||||
pub const subtype = .node;
|
||||
|
||||
handle: *const v8.Integer,
|
||||
|
||||
pub fn init(isolate: *v8.Isolate, value: anytype) Integer {
|
||||
const handle = switch (@TypeOf(value)) {
|
||||
i8, i16, i32 => v8.v8__Integer__New(isolate, value).?,
|
||||
u8, u16, u32 => v8.v8__Integer__NewFromUnsigned(isolate, value).?,
|
||||
else => |T| @compileError("cannot create v8::Integer from: " ++ @typeName(T)),
|
||||
};
|
||||
return .{ .handle = handle };
|
||||
pub fn _submit(self: *parser.Form, page: *Page) !void {
|
||||
return page.submitForm(self, null);
|
||||
}
|
||||
|
||||
pub fn _reset(self: *parser.Form) !void {
|
||||
try parser.formElementReset(self);
|
||||
}
|
||||
};
|
||||
120
src/browser/html/history.zig
Normal file
120
src/browser/html/history.zig
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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");
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface
|
||||
pub const History = struct {
|
||||
const ScrollRestorationMode = enum {
|
||||
auto,
|
||||
manual,
|
||||
};
|
||||
|
||||
scrollRestoration: ScrollRestorationMode = .auto,
|
||||
state: std.json.Value = .null,
|
||||
|
||||
// count tracks the history length until we implement correctly pushstate.
|
||||
count: u32 = 0,
|
||||
|
||||
pub fn get_length(self: *History) u32 {
|
||||
// TODO return the real history length value.
|
||||
return self.count;
|
||||
}
|
||||
|
||||
pub fn get_scrollRestoration(self: *History) []const u8 {
|
||||
return switch (self.scrollRestoration) {
|
||||
.auto => "auto",
|
||||
.manual => "manual",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn set_scrollRestoration(self: *History, mode: []const u8) void {
|
||||
if (std.mem.eql(u8, "manual", mode)) self.scrollRestoration = .manual;
|
||||
if (std.mem.eql(u8, "auto", mode)) self.scrollRestoration = .auto;
|
||||
}
|
||||
|
||||
pub fn get_state(self: *History) std.json.Value {
|
||||
return self.state;
|
||||
}
|
||||
|
||||
// TODO implement the function
|
||||
// data must handle any argument. We could expect a std.json.Value but
|
||||
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
|
||||
pub fn _pushState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
|
||||
self.count += 1;
|
||||
_ = url;
|
||||
_ = data;
|
||||
}
|
||||
|
||||
// TODO implement the function
|
||||
// data must handle any argument. We could expect a std.json.Value but
|
||||
// https://github.com/lightpanda-io/zig-js-runtime/issues/267 is missing.
|
||||
pub fn _replaceState(self: *History, data: []const u8, _: ?[]const u8, url: ?[]const u8) void {
|
||||
_ = self;
|
||||
_ = url;
|
||||
_ = data;
|
||||
}
|
||||
|
||||
// TODO implement the function
|
||||
pub fn _go(self: *History, delta: ?i32) void {
|
||||
_ = self;
|
||||
_ = delta;
|
||||
}
|
||||
|
||||
// TODO implement the function
|
||||
pub fn _back(self: *History) void {
|
||||
_ = self;
|
||||
}
|
||||
|
||||
// TODO implement the function
|
||||
pub fn _forward(self: *History) void {
|
||||
_ = self;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.History" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "history.scrollRestoration", "auto" },
|
||||
.{ "history.scrollRestoration = 'manual'", "manual" },
|
||||
.{ "history.scrollRestoration = 'foo'", "foo" },
|
||||
.{ "history.scrollRestoration", "manual" },
|
||||
.{ "history.scrollRestoration = 'auto'", "auto" },
|
||||
.{ "history.scrollRestoration", "auto" },
|
||||
|
||||
.{ "history.state", "null" },
|
||||
|
||||
.{ "history.pushState({}, null, '')", "undefined" },
|
||||
|
||||
.{ "history.replaceState({}, null, '')", "undefined" },
|
||||
|
||||
.{ "history.go()", "undefined" },
|
||||
.{ "history.go(1)", "undefined" },
|
||||
.{ "history.go(-1)", "undefined" },
|
||||
|
||||
.{ "history.forward()", "undefined" },
|
||||
|
||||
.{ "history.back()", "undefined" },
|
||||
}, .{});
|
||||
}
|
||||
41
src/browser/html/html.zig
Normal file
41
src/browser/html/html.zig
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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 HTMLDocument = @import("document.zig").HTMLDocument;
|
||||
const HTMLElem = @import("elements.zig");
|
||||
const SVGElem = @import("svg_elements.zig");
|
||||
const Window = @import("window.zig").Window;
|
||||
const Navigator = @import("navigator.zig").Navigator;
|
||||
const History = @import("history.zig").History;
|
||||
const Location = @import("location.zig").Location;
|
||||
const MediaQueryList = @import("media_query_list.zig").MediaQueryList;
|
||||
const Performance = @import("performance.zig").Performance;
|
||||
|
||||
pub const Interfaces = .{
|
||||
HTMLDocument,
|
||||
HTMLElem.HTMLElement,
|
||||
HTMLElem.HTMLMediaElement,
|
||||
HTMLElem.Interfaces,
|
||||
SVGElem.SVGElement,
|
||||
Window,
|
||||
Navigator,
|
||||
History,
|
||||
Location,
|
||||
MediaQueryList,
|
||||
Performance,
|
||||
};
|
||||
106
src/browser/html/location.zig
Normal file
106
src/browser/html/location.zig
Normal file
@@ -0,0 +1,106 @@
|
||||
// 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", "" },
|
||||
}, .{});
|
||||
}
|
||||
45
src/browser/html/media_query_list.zig
Normal file
45
src/browser/html/media_query_list.zig
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const Function = @import("../env.zig").Function;
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
// https://drafts.csswg.org/cssom-view/#the-mediaquerylist-interface
|
||||
pub const MediaQueryList = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
// This is not safe as it relies on a structure layout that isn't guaranteed
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
matches: bool,
|
||||
media: []const u8,
|
||||
|
||||
pub fn get_matches(self: *const MediaQueryList) bool {
|
||||
return self.matches;
|
||||
}
|
||||
|
||||
pub fn get_media(self: *const MediaQueryList) []const u8 {
|
||||
return self.media;
|
||||
}
|
||||
|
||||
pub fn _addListener(_: *const MediaQueryList, _: Function) void {}
|
||||
|
||||
pub fn _removeListener(_: *const MediaQueryList, _: Function) void {}
|
||||
};
|
||||
96
src/browser/html/navigator.zig
Normal file
96
src/browser/html/navigator.zig
Normal file
@@ -0,0 +1,96 @@
|
||||
// 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");
|
||||
|
||||
// https://html.spec.whatwg.org/multipage/system-state.html#navigator
|
||||
pub const Navigator = struct {
|
||||
agent: []const u8 = "Lightpanda/1.0",
|
||||
version: []const u8 = "1.0",
|
||||
vendor: []const u8 = "",
|
||||
platform: []const u8 = std.fmt.comptimePrint("{any} {any}", .{ builtin.os.tag, builtin.cpu.arch }),
|
||||
|
||||
language: []const u8 = "en-US",
|
||||
|
||||
pub fn get_userAgent(self: *Navigator) []const u8 {
|
||||
return self.agent;
|
||||
}
|
||||
pub fn get_appCodeName(_: *Navigator) []const u8 {
|
||||
return "Mozilla";
|
||||
}
|
||||
pub fn get_appName(_: *Navigator) []const u8 {
|
||||
return "Netscape";
|
||||
}
|
||||
pub fn get_appVersion(self: *Navigator) []const u8 {
|
||||
return self.version;
|
||||
}
|
||||
pub fn get_platform(self: *Navigator) []const u8 {
|
||||
return self.platform;
|
||||
}
|
||||
pub fn get_product(_: *Navigator) []const u8 {
|
||||
return "Gecko";
|
||||
}
|
||||
pub fn get_productSub(_: *Navigator) []const u8 {
|
||||
return "20030107";
|
||||
}
|
||||
pub fn get_vendor(self: *Navigator) []const u8 {
|
||||
return self.vendor;
|
||||
}
|
||||
pub fn get_vendorSub(_: *Navigator) []const u8 {
|
||||
return "";
|
||||
}
|
||||
pub fn get_language(self: *Navigator) []const u8 {
|
||||
return self.language;
|
||||
}
|
||||
// TODO wait for arrays.
|
||||
//pub fn get_languages(self: *Navigator) [][]const u8 {
|
||||
// return .{self.language};
|
||||
//}
|
||||
pub fn get_online(_: *Navigator) bool {
|
||||
return true;
|
||||
}
|
||||
pub fn _registerProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
|
||||
_ = scheme;
|
||||
_ = url;
|
||||
}
|
||||
pub fn _unregisterProtocolHandler(_: *Navigator, scheme: []const u8, url: []const u8) void {
|
||||
_ = scheme;
|
||||
_ = url;
|
||||
}
|
||||
|
||||
pub fn get_cookieEnabled(_: *Navigator) bool {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Tests
|
||||
// -----
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Browser.HTML.Navigator" {
|
||||
var runner = try testing.jsRunner(testing.tracking_allocator, .{});
|
||||
defer runner.deinit();
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "navigator.userAgent", "Lightpanda/1.0" },
|
||||
.{ "navigator.appVersion", "1.0" },
|
||||
.{ "navigator.language", "en-US" },
|
||||
}, .{});
|
||||
}
|
||||
87
src/browser/html/performance.zig
Normal file
87
src/browser/html/performance.zig
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as
|
||||
// published by the Free Software Foundation, either version 3 of the
|
||||
// License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const parser = @import("../netsurf.zig");
|
||||
const EventTarget = @import("../dom/event_target.zig").EventTarget;
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Performance
|
||||
pub const Performance = struct {
|
||||
pub const prototype = *EventTarget;
|
||||
|
||||
// Extend libdom event target for pure zig struct.
|
||||
base: parser.EventTargetTBase = parser.EventTargetTBase{},
|
||||
|
||||
time_origin: std.time.Timer,
|
||||
// if (Window.crossOriginIsolated) -> Resolution in isolated contexts: 5 microseconds
|
||||
// else -> Resolution in non-isolated contexts: 100 microseconds
|
||||
const ms_resolution = 100;
|
||||
|
||||
fn limitedResolutionMs(nanoseconds: u64) f64 {
|
||||
const elapsed_at_resolution = ((nanoseconds / std.time.ns_per_us) + ms_resolution / 2) / ms_resolution * ms_resolution;
|
||||
const elapsed = @as(f64, @floatFromInt(elapsed_at_resolution));
|
||||
return elapsed / @as(f64, std.time.us_per_ms);
|
||||
}
|
||||
|
||||
pub fn get_timeOrigin(self: *const Performance) f64 {
|
||||
const is_posix = switch (@import("builtin").os.tag) { // From std.time.zig L125
|
||||
.windows, .uefi, .wasi => false,
|
||||
else => true,
|
||||
};
|
||||
const zero = std.time.Instant{ .timestamp = if (!is_posix) 0 else .{ .sec = 0, .nsec = 0 } };
|
||||
const started = self.time_origin.started.since(zero);
|
||||
return limitedResolutionMs(started);
|
||||
}
|
||||
|
||||
pub fn _now(self: *Performance) f64 {
|
||||
return limitedResolutionMs(self.time_origin.read());
|
||||
}
|
||||
};
|
||||
|
||||
const testing = @import("./../../testing.zig");
|
||||
|
||||
test "Performance: get_timeOrigin" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
const time_origin = perf.get_timeOrigin();
|
||||
try testing.expect(time_origin >= 0);
|
||||
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(time_origin * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
|
||||
test "Performance: now" {
|
||||
var perf = Performance{ .time_origin = try std.time.Timer.start() };
|
||||
|
||||
// Monotonically increasing
|
||||
var now = perf._now();
|
||||
while (now <= 0) { // Loop for now to not be 0
|
||||
try testing.expectEqual(now, 0);
|
||||
now = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(now * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
|
||||
var after = perf._now();
|
||||
while (after <= now) { // Loop untill after > now
|
||||
try testing.expectEqual(after, now);
|
||||
after = perf._now();
|
||||
}
|
||||
// Check resolution
|
||||
try testing.expectDelta(@rem(after * std.time.us_per_ms, 100.0), 0.0, 0.1);
|
||||
}
|
||||
144
src/browser/html/select.zig
Normal file
144
src/browser/html/select.zig
Normal file
@@ -0,0 +1,144 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
41
src/browser/html/svg_elements.zig
Normal file
41
src/browser/html/svg_elements.zig
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
390
src/browser/html/window.zig
Normal file
390
src/browser/html/window.zig
Normal file
@@ -0,0 +1,390 @@
|
||||
// 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("performance.zig").Performance;
|
||||
const CSSStyleDeclaration = @import("../cssom/css_style_declaration.zig").CSSStyleDeclaration;
|
||||
|
||||
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,
|
||||
|
||||
pub fn create(target: ?[]const u8, navigator: ?Navigator) !Window {
|
||||
var fbs = std.io.fixedBufferStream("");
|
||||
const Elements = @import("../html/elements.zig");
|
||||
const html_doc = try parser.documentHTMLParse(fbs.reader(), "utf-8", &Elements.createElement);
|
||||
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 _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),
|
||||
};
|
||||
}
|
||||
|
||||
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(_: *const Window, opts: ScrollToOpts, y: ?u32) void {
|
||||
_ = opts;
|
||||
_ = y;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
\\ function step(timestamp) {
|
||||
\\ if (start === undefined) {
|
||||
\\ start = timestamp;
|
||||
\\ }
|
||||
\\ const elapsed = timestamp - start;
|
||||
\\ if (elapsed < 2000) {
|
||||
\\ requestAnimationFrame(step);
|
||||
\\ }
|
||||
\\ }
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "requestAnimationFrame(step);", null }, // returned id is checked in the next test
|
||||
}, .{});
|
||||
|
||||
// cancelAnimationFrame should be able to cancel a request with the given id
|
||||
try runner.testCases(&.{
|
||||
.{ "let request_id = requestAnimationFrame(timestamp => {});", null },
|
||||
.{ "cancelAnimationFrame(request_id);", "undefined" },
|
||||
}, .{});
|
||||
|
||||
try runner.testCases(&.{
|
||||
.{ "innerHeight", "1" },
|
||||
.{ "innerWidth", "1" }, // Width is 1 even if there are no elements
|
||||
.{
|
||||
\\ let div1 = document.createElement('div');
|
||||
\\ document.body.appendChild(div1);
|
||||
\\ div1.getClientRects();
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{
|
||||
\\ let div2 = document.createElement('div');
|
||||
\\ document.body.appendChild(div2);
|
||||
\\ div2.getClientRects();
|
||||
,
|
||||
null,
|
||||
},
|
||||
.{ "innerHeight", "1" },
|
||||
.{ "innerWidth", "2" },
|
||||
}, .{});
|
||||
|
||||
// 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" },
|
||||
}, .{});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user