summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-07-10 17:14:13 +0300
committerGitHub <noreply@github.com>2026-07-10 16:14:13 +0200
commit21689ef59b0eb34b1a29eda739dc10d33d25b44a (patch)
tree95883c2ee70d4c09562ae3e0289e18f34999240c
parent6d2d28d4411ff52ad0f9314cf3696336748ab89d (diff)
downloadvyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.tar.gz
vyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.zip
docs: Cloudflare Workers hosting pipeline (apex, content workers, CI, previews) (#2140)
* docs-infra: scaffold Cloudflare workers workspace (versions.json v2, matrix, toolchain) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: record full Phase-0 plan decision in workers/PLAN.md πŸ€– Generated by [robots](https://vyos.io) * docs-infra: shared content worker β€” asset serving, cache classes, X-Docs-Build, canary no-store πŸ€– Generated by [robots](https://vyos.io) * docs-infra: run worker script before assets; test fetch entrypoint πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex manifest loader + dispatch map + runtime binding guard (TDD) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex redirects (aliases, PDF, trailing-slash) + special paths (TDD) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: PDF redirect honors pdf:null and preserves query πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex UA gate β€” allowlist-wins, log-only AI crawlers, empty block list at launch πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex router (pipeline Β§3.2), themed 404/503, /kb seam, env configs + congruence test πŸ€– Generated by [robots](https://vyos.io) * docs-infra: add missing-User-Agent regression test for apex UA gate πŸ€– Generated by [robots](https://vyos.io) * docs-infra: R2-streaming preview worker β€” MIME map, noindex, no-store (TDD) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: preview 404 no-store + fetch handler tests πŸ€– Generated by [robots](https://vyos.io) * docs-infra: bootstrap script β€” binding-target workers must exist before apex deploys πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex run_worker_first, lockfile for npm ci, PDF Location from manifest πŸ€– Generated by [robots](https://vyos.io) * docs-infra: derive html_baseurl from DOCS_VERSION_SLUG with RTD fallback (canonical gate prereq) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: version picker + status banner + language scaffold (vanilla JS, TDD pure core) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: picker preserves query+hash across switch; valid breadcrumb markup πŸ€– Generated by [robots](https://vyos.io) * docs-infra: Pagefind search wrapper with runtime base-path + preview prefix handling (TDD) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: pagefind wrapper β€” asset-failure notice + UI stylesheet load πŸ€– Generated by [robots](https://vyos.io) * docs-infra: gate Pagefind searchbox to CF builds (RTD keeps stock search until cutover) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: deploy sanity gates β€” limits, critical pages, count-delta, canonical (TDD) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: hermetic gate tests via fixture versions.json πŸ€– Generated by [robots](https://vyos.io) * docs-infra: docs-build workflow β€” candidate/smoke/promote two-stage deploy + registry + rollback Two-stage CF Workers pipeline: build in pinned container, assemble artifact, sanity gates, deploy candidate, scoped pre-traffic smoke via canary apex, promote (rollback-id capture, hostname purge, registry upload), post-promote probe + auto-rollback. DOCS_CF_LIVE repo variable gates every docs.vyos.io production interaction pre-cutover. scripts/docs_gates/smoke.py adds one authorized check beyond the spec: the version's index.html probe asserts the #vyos-search mount div is present in the response body, guarding CI silently forgetting DOCS_VERSION_SLUG (which would otherwise ship stock RTD search without the Pagefind gate noticing). πŸ€– Generated by [robots](https://vyos.io) * docs-infra: build docs image in-workflow with buildx cache (v4.1 β€” digest pin dropped) Plan v4.1 amendment: the ghcr.io digest-pinned image does not exist (workflow would hard-fail at the first docker step on every push). Replace the BUILD_IMAGE env placeholder with an in-workflow docker build from docker/Dockerfile via docker/setup-buildx-action@v3 + docker/build-push-action@v6 (context: docker/, load: true, tags: docs-build:local, GHA cache from/to). The checked-out commit is the pin; buildx GHA cache keeps repeat builds cheap. Sphinx-build step swaps to docs-build:local; inner script unchanged. πŸ€– Generated by [robots](https://vyos.io) * docs-infra: apex/preview deploy workflow β€” canary auto, production behind environment approval * docs-infra: apex-deploy concurrency guard (per-ref, cancel-in-progress) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: fork-safe PR preview pipeline β€” approval record, R2 prefixes, label consumption, cleanup * docs-infra: nightly preview sweep β€” pipefail + per-prefix failure isolation πŸ€– Generated by [robots](https://vyos.io) * docs-infra: nightly canary QA β€” per-entry sweep + URL-parity corpus vs RTD πŸ€– Generated by [robots](https://vyos.io) * docs-infra: parity sweep scoped to CF-built versions; transport-error resilience πŸ€– Generated by [robots](https://vyos.io) * docs-infra: one-off bootstrap workflow (binding targets β€” runs once on this push) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: remove one-off bootstrap workflow (bootstrap complete) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: one-off canary apex + preview deploy (route targets for Task 3.6 step 2c) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: remove one-off canary deploy workflow (targets live) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: address Phase-0 CodeRabbit findings (canonical gate, error caching, registry pointer, validation) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: strengthen manifest tests (full dispatch iteration, mutation-free validate) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: address GitHub CodeRabbit review (pointer-after-probe, fail-closed sweeps, block-precedence UA gate, preview hardening) πŸ€– Generated by [robots](https://vyos.io) * docs-infra: adversarial review fixes β€” error no-store, probe retry, PR-list membership, preview dotted-segment πŸ€– Generated by [robots](https://vyos.io) * docs-infra: serve oversized legacy PDF from R2 via apex (spec Β§5 fallback) The 1.3 PDF (29.2 MiB) exceeds the 25 MiB static-asset cap and is absent from the legacy content Worker's build, so /_/downloads/en/1.3/pdf/ (and the picker's PDF link) 301'd into a dead-end 404 post-cutover. Add the R2 object fallback spec Β§5 already documented but never implemented: a DOCS_PDFS R2 bucket binding on the apex Worker, a manifest pdf_r2_key field (1.3 only), and a router step ahead of version dispatch that streams the object with its own cache class (canary/error still force no-store). πŸ€– Generated by [robots](https://vyos.io) * docs-infra: PDF R2 fallback honors Range + If-None-Match, preserves ETag πŸ€– Generated by [robots](https://vyos.io)
-rw-r--r--.github/workflows/apex-deploy.yml74
-rw-r--r--.github/workflows/docs-build.yml340
-rw-r--r--.github/workflows/docs-canary-qa.yml47
-rw-r--r--.github/workflows/docs-preview-approve.yml35
-rw-r--r--.github/workflows/docs-preview-build.yml26
-rw-r--r--.github/workflows/docs-preview-cleanup.yml43
-rw-r--r--.github/workflows/docs-preview-deploy.yml86
-rw-r--r--docs/_static/css/version-picker.css11
-rw-r--r--docs/_static/js/pagefind-wrapper.js69
-rw-r--r--docs/_static/js/version-picker.js153
-rw-r--r--docs/_templates/breadcrumbs.html5
-rw-r--r--docs/_templates/searchbox.html13
-rw-r--r--docs/conf.py36
-rw-r--r--scripts/docs_gates/__init__.py0
-rw-r--r--scripts/docs_gates/conftest.py55
-rw-r--r--scripts/docs_gates/critical-pages.txt9
-rw-r--r--scripts/docs_gates/gates.py93
-rw-r--r--scripts/docs_gates/parity.py121
-rw-r--r--scripts/docs_gates/smoke.py117
-rw-r--r--scripts/docs_gates/test_gates.py104
-rw-r--r--scripts/docs_gates/test_parity.py72
-rw-r--r--scripts/docs_gates/test_smoke.py70
-rw-r--r--workers/.gitignore3
-rw-r--r--workers/PLAN.md5
-rw-r--r--workers/apex/assets/404.html23
-rw-r--r--workers/apex/assets/503.html24
-rw-r--r--workers/apex/assets/apple-touch-icon.pngbin0 -> 4128 bytes
-rw-r--r--workers/apex/assets/favicon.icobin0 -> 766 bytes
-rw-r--r--workers/apex/assets/robots.txt3
-rw-r--r--workers/apex/assets/root.html30
-rw-r--r--workers/apex/src/dispatch.ts15
-rw-r--r--workers/apex/src/index.ts159
-rw-r--r--workers/apex/src/manifest.ts55
-rw-r--r--workers/apex/src/redirects.ts38
-rw-r--r--workers/apex/src/special.ts55
-rw-r--r--workers/apex/src/uagate.ts18
-rw-r--r--workers/apex/test/dispatch.test.ts29
-rw-r--r--workers/apex/test/manifest.test.ts129
-rw-r--r--workers/apex/test/redirects.test.ts54
-rw-r--r--workers/apex/test/router.test.ts334
-rw-r--r--workers/apex/test/uagate.test.ts24
-rw-r--r--workers/apex/ua-policy.json5
-rw-r--r--workers/apex/wrangler.jsonc39
-rwxr-xr-xworkers/bootstrap.sh29
-rw-r--r--workers/branch/src/index.ts44
-rw-r--r--workers/branch/test/content.test.ts80
-rw-r--r--workers/branch/wrangler.legacy.jsonc16
-rw-r--r--workers/branch/wrangler.rolling.jsonc16
-rw-r--r--workers/branch/wrangler.v14.jsonc16
-rw-r--r--workers/branch/wrangler.v15.jsonc16
-rw-r--r--workers/matrix.json5
-rw-r--r--workers/package-lock.json3921
-rw-r--r--workers/package.json16
-rw-r--r--workers/picker-test/pagefind-wrapper.test.ts42
-rw-r--r--workers/picker-test/picker.test.ts64
-rw-r--r--workers/preview/src/index.ts61
-rw-r--r--workers/preview/test/preview.test.ts93
-rw-r--r--workers/preview/wrangler.jsonc11
-rw-r--r--workers/versions.json19
-rw-r--r--workers/vitest.config.ts15
60 files changed, 7083 insertions, 2 deletions
diff --git a/.github/workflows/apex-deploy.yml b/.github/workflows/apex-deploy.yml
new file mode 100644
index 00000000..1528221b
--- /dev/null
+++ b/.github/workflows/apex-deploy.yml
@@ -0,0 +1,74 @@
+name: Apex + preview worker deploy
+
+on:
+ push:
+ branches: [rolling]
+ paths: ["workers/**"]
+ workflow_dispatch: {}
+
+concurrency:
+ group: apex-deploy-${{ github.ref_name }}
+ # false (not true): a mid-job cancel can leave the apex/preview worker pair
+ # half-updated; queuing subsequent runs is safe.
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: 22 }
+ - run: cd workers && npm ci && npx vitest run
+
+ deploy-canary:
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: 22 }
+ - name: Preflight β€” every service-binding target must already exist (Task 1.8)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -eu
+ cd workers && npm ci
+ missing=0
+ for w in vyos-docs-rolling-en vyos-docs-v15-en vyos-docs-v14-en vyos-docs-legacy; do
+ for suffix in "" "-candidate"; do
+ npx wrangler deployments list --name "$w$suffix" --json >/dev/null 2>&1 \
+ || { echo "::error::binding target $w$suffix does not exist β€” run workers/bootstrap.sh first (Task 1.8)"; missing=1; }
+ done
+ done
+ exit $missing
+ - name: Deploy apex (canary) + preview
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ cd workers && npm ci
+ npx wrangler deploy --config apex/wrangler.jsonc --env canary \
+ --var APEX_BUILD_SHA:'${{ github.sha }}'
+ npx wrangler deploy --config preview/wrangler.jsonc
+
+ deploy-production:
+ needs: deploy-canary
+ runs-on: ubuntu-latest
+ environment: docs-production # requires reviewer approval (Β§7.2)
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with: { node-version: 22 }
+ - name: Deploy apex (production)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ cd workers && npm ci
+ npx wrangler deploy --config apex/wrangler.jsonc --env production \
+ --var APEX_BUILD_SHA:'${{ github.sha }}'
diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
new file mode 100644
index 00000000..fc9a2239
--- /dev/null
+++ b/.github/workflows/docs-build.yml
@@ -0,0 +1,340 @@
+name: Docs build + deploy (Cloudflare Workers)
+
+on:
+ push:
+ branches: [rolling, circinus, sagitta]
+ workflow_dispatch:
+ inputs:
+ skip_pdf:
+ description: "SKIP_PDF: carry forward last-good PDF from registry (broken-LaTeX escape hatch)"
+ type: boolean
+ default: false
+
+concurrency:
+ group: docs-build-${{ github.ref_name }}
+ # false (not true): a cancel mid-promote would leave production unverified and the
+ # registry pointer stale β€” queued runs are safe because the check_head guard (Β§7.1)
+ # skips any run whose SHA is no longer the branch tip.
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ REGISTRY_BUCKET: vyos-docs-artifacts
+
+jobs:
+ build-deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Resolve matrix entry
+ id: matrix
+ run: |
+ set -eu
+ entry=$(jq -r --arg b "${{ github.ref_name }}" '.[$b] // empty' workers/matrix.json)
+ [ -n "$entry" ] || { echo "branch not in matrix"; exit 1; }
+ echo "worker=$(echo "$entry" | jq -r .worker)" >> "$GITHUB_OUTPUT"
+ echo "slug=$(echo "$entry" | jq -r .slug)" >> "$GITHUB_OUTPUT"
+ echo "pdf=$(jq -r --arg s "$(echo "$entry" | jq -r .slug)" \
+ '.versions[] | select(.slug==$s) | .pdf // empty' workers/versions.json)" >> "$GITHUB_OUTPUT"
+
+ # Build image in-workflow from docker/Dockerfile (plan v4.1: digest pin dropped β€”
+ # no published ghcr.io image exists; the checked-out commit IS the pin, and the
+ # buildx GHA cache makes repeat builds cheap).
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build docs image (buildx GHA cache)
+ uses: docker/build-push-action@v6
+ with:
+ context: docker/
+ load: true
+ tags: docs-build:local
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ - name: Build HTML + PDF in in-workflow-built container
+ run: |
+ set -eu
+ docker run --rm -v "$PWD:/src" -w /src \
+ -e DOCS_VERSION_SLUG="${{ steps.matrix.outputs.slug }}" \
+ -e DOCS_VERSION_BRANCH="${{ github.ref_name }}" \
+ docs-build:local bash -c '
+ cd docs && make html
+ if [ "${{ inputs.skip_pdf }}" != "true" ]; then
+ make latexpdf || make latexpdf # retry once (Β§14 LaTeX flakiness)
+ fi'
+
+ - name: Assemble artifact (nest under en/<slug>/, Β§7.1)
+ run: |
+ set -eu
+ slug='${{ steps.matrix.outputs.slug }}'
+ mkdir -p "dist/assets/en/$slug"
+ cp -r docs/_build/html/. "dist/assets/en/$slug/"
+ npx --yes pagefind@1.5.2 --site "dist/assets/en/$slug"
+
+ - name: PDF into artifact (build or registry carry-forward)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }}
+ run: |
+ set -eu
+ slug='${{ steps.matrix.outputs.slug }}'
+ dest="dist/assets/en/$slug/vyos-documentation.pdf"
+ if [ "${{ inputs.skip_pdf }}" = "true" ]; then
+ # Β§5 + Β§7.1.4: SKIP_PDF refuses to run when registry is stale vs production.
+ # Registry layout is pointer-indirected (Β§7.1.4 atomicity): resolve
+ # $slug/latest.json β†’ sha, then read the sha-scoped generation.
+ cd workers && npm ci && npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/latest.json" --file /tmp/latest.json --remote && cd ..
+ reg_sha=$(jq -r .sha /tmp/latest.json)
+ if [ "$DOCS_CF_LIVE" = "true" ]; then
+ prod_sha=$(curl -sI "https://docs.vyos.io/en/$slug/" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-docs-build"{print $2}')
+ [ "$reg_sha" = "$prod_sha" ] || { echo "registry stale ($reg_sha != $prod_sha) β€” SKIP_PDF refused"; exit 1; }
+ else
+ echo "DOCS_CF_LIVE=false β€” pre-cutover: trusting registry meta without production comparison"
+ fi
+ cd workers && npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/$reg_sha/pdf" --file "../$dest" --remote && cd ..
+ else
+ cp docs/_build/latex/*.pdf "$dest"
+ fi
+
+ - name: Fetch previous-build metadata (for count-delta gate)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -eo pipefail
+ set -u
+ slug='${{ steps.matrix.outputs.slug }}'
+ cd workers
+ # Missing-object (no pointer yet, or the pointed-at generation's meta.json is
+ # gone) is the legitimate bootstrap case β€” this slug has never shipped before,
+ # so proceed with no previous-meta.json (the count-delta gate below no-ops when
+ # the file is absent). Any OTHER failure (auth, network, transient API error)
+ # must FAIL the workflow β€” silently swallowing those would bypass the
+ # count-collapse gate instead of just skipping it on a genuinely first deploy.
+ fetch_optional() {
+ set +e
+ out=$(npx wrangler r2 object get "$1" --file "$2" --remote 2>&1)
+ rc=$?
+ set -e
+ echo "$out"
+ [ "$rc" -eq 0 ] && return 0
+ if echo "$out" | grep -qiE 'does not exist|not found|no such key|404'; then
+ return 1
+ fi
+ echo "::error::registry fetch of $1 failed for a reason other than 'missing object' β€” failing the build (see spec Β§7.1 count-delta gate)"
+ exit "$rc"
+ }
+ if fetch_optional "$REGISTRY_BUCKET/$slug/latest.json" /tmp/latest.json; then
+ reg_sha=$(jq -r .sha /tmp/latest.json)
+ if [ -z "$reg_sha" ] || [ "$reg_sha" = "null" ]; then
+ echo "::error::registry pointer latest.json is malformed (no .sha)"
+ exit 1
+ fi
+ if ! fetch_optional "$REGISTRY_BUCKET/$slug/$reg_sha/meta.json" ../previous-meta.json; then
+ echo "::notice::registry pointer names $reg_sha but its meta.json is missing β€” treating as bootstrap"
+ fi
+ else
+ echo "::notice::no registry entry yet for $slug β€” treating as bootstrap (no previous-meta)"
+ fi
+
+ - name: Sanity gates (deploy blockers, Β§7.1)
+ run: |
+ python -m scripts.docs_gates.gates \
+ --artifact dist/assets --slug '${{ steps.matrix.outputs.slug }}' \
+ --versions workers/versions.json \
+ $( [ -f previous-meta.json ] && echo --previous-meta previous-meta.json )
+
+ - name: check_head guard (Β§7.1)
+ run: |
+ set -eu
+ remote=$(git ls-remote origin "refs/heads/${{ github.ref_name }}" | cut -f1)
+ [ "$remote" = "${{ github.sha }}" ] || { echo "branch moved β€” skipping deploy"; exit 78; }
+
+ - name: Install workers deps
+ run: cd workers && npm ci
+
+ - name: Deploy CANDIDATE
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ cd workers && npx wrangler deploy --config branch/wrangler.$( \
+ case '${{ steps.matrix.outputs.slug }}' in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac \
+ ).jsonc \
+ --name '${{ steps.matrix.outputs.worker }}-candidate' \
+ --var DOCS_BUILD_SHA:'${{ github.sha }}' --var DOCS_ENV:canary
+
+ - name: Pre-traffic smoke via canary apex (Β§7.1.2)
+ id: smoke
+ run: |
+ set -eu
+ # NOTE: never use the GHA "cond AND format(...)" expression trick for optional
+ # args here β€” an empty/false result renders the literal string "false" into the
+ # shell. (Any double-curly GHA expression written literally in this script gets
+ # template-expanded by GitHub Actions before the shell ever runs it, so such an
+ # example cannot even be spelled out in a comment here.) Plain shell instead:
+ pdf_arg=""
+ if [ -n "${{ steps.matrix.outputs.pdf }}" ]; then
+ pdf_arg="--pdf ${{ steps.matrix.outputs.pdf }}"
+ fi
+ python -m scripts.docs_gates.smoke \
+ --host docs-next.vyos.io --slug '${{ steps.matrix.outputs.slug }}' \
+ --expect-sha '${{ github.sha }}' \
+ --access-id '${{ secrets.CF_ACCESS_CLIENT_ID }}' \
+ --access-secret '${{ secrets.CF_ACCESS_CLIENT_SECRET }}' \
+ $pdf_arg
+
+ - name: Candidate reset on smoke failure (Β§7.1.3)
+ if: failure() && steps.smoke.conclusion == 'failure'
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }}
+ run: |
+ set -eu
+ slug='${{ steps.matrix.outputs.slug }}'
+ cd workers
+ # Registry layout is pointer-indirected (Β§7.1.4 atomicity): resolve
+ # $slug/latest.json β†’ sha, then read the sha-scoped generation.
+ npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/latest.json" --file /tmp/latest.json --remote \
+ || { echo "::warning::no registry entry yet (first deploys) β€” candidate reset skipped"; exit 0; }
+ reg_sha=$(jq -r .sha /tmp/latest.json)
+ if [ "$DOCS_CF_LIVE" = "true" ]; then
+ prod_sha=$(curl -sI "https://docs.vyos.io/en/$slug/" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-docs-build"{print $2}')
+ [ "$reg_sha" = "$prod_sha" ] || { echo "::warning::registry stale β€” candidate reset SKIPPED (manual registry-repair needed)"; exit 0; }
+ fi
+ npx wrangler r2 object get "$REGISTRY_BUCKET/$slug/$reg_sha/tar.zst" --file /tmp/lastgood.tar.zst --remote
+ rm -rf ../dist/assets && mkdir -p ../dist/assets
+ tar --zstd -xf /tmp/lastgood.tar.zst -C ../dist/assets
+ npx wrangler deploy --config branch/wrangler.$(case "$slug" in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac).jsonc \
+ --name '${{ steps.matrix.outputs.worker }}-candidate' \
+ --var DOCS_BUILD_SHA:"$reg_sha" --var DOCS_ENV:canary
+
+ - name: PROMOTE (capture rollback id β†’ deploy β†’ purge β†’ registry, Β§7.1.4)
+ id: promote
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ CF_ZONE_ID: ${{ vars.CF_ZONE_ID_VYOS_IO }}
+ run: |
+ set -eu
+ slug='${{ steps.matrix.outputs.slug }}'
+ worker='${{ steps.matrix.outputs.worker }}'
+ cd workers
+ # Rollback target: verify this JSON shape on the FIRST real deploy β€”
+ # `npx wrangler deployments list --name "$worker" --json | jq .` β€” and adjust
+ # the jq path if wrangler's output differs (their JSON shape has churned across
+ # majors; the ID needed is the CURRENT deployment's, i.e. the newest entry).
+ # FIRST-DEPLOY BOOTSTRAP: the production Worker does not exist before the very
+ # first promote β€” `deployments list` fails/returns empty. That is a valid state:
+ # rollback_id stays empty, the deploy CREATES the Worker, and the post-promote
+ # step knows an empty id means "nothing to roll back to".
+ if deps=$(npx wrangler deployments list --name "$worker" --json 2>/dev/null); then
+ rollback_id=$(echo "$deps" | jq -r '.[0].id // empty')
+ else
+ rollback_id=""
+ echo "first deploy for $worker β€” no prior deployment; rollback disabled for this run"
+ fi
+ echo "rollback_id=$rollback_id" >> "$GITHUB_OUTPUT"
+ npx wrangler deploy --config branch/wrangler.$(case "$slug" in rolling) echo rolling;; 1.5) echo v15;; 1.4) echo v14;; esac).jsonc \
+ --name "$worker" --var DOCS_BUILD_SHA:'${{ github.sha }}' --var DOCS_ENV:production
+ if [ "${{ vars.DOCS_CF_LIVE }}" = "true" ]; then
+ # hostname-scoped purge (Β§3.3; all-plans since 2025-04)
+ curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
+ --data '{"hosts":["docs.vyos.io"]}'
+ else
+ echo "DOCS_CF_LIVE=false β€” pre-cutover: skipping production hostname purge"
+ fi
+ # registry generation upload β€” SHA-scoped immutable objects (Β§7.1.4). The
+ # $slug/latest.json POINTER is deliberately NOT published here: it moves to a
+ # dedicated step AFTER the post-promote probe/auto-rollback below, so a probe
+ # failure (and the rollback it triggers) leaves the pointer on the previous
+ # good generation instead of advancing it to a generation that just got rolled
+ # back. Uploading straight to $slug/last-good.* let a concurrent reader observe
+ # a half-written generation (e.g. new tar.zst, still-old meta.json) mid upload.
+ # Uploading under $slug/<sha>/* first β€” a key no prior generation ever reused β€”
+ # keeps readers of the OLD pointer seeing a full, untouched old generation.
+ cd .. && tar --zstd -cf lastgood.tar.zst -C dist/assets .
+ page_count=$(find "dist/assets/en/$slug" -name '*.html' | wc -l | tr -d ' ')
+ sha='${{ github.sha }}'
+ printf '{"sha":"%s","page_count":%s}' "$sha" "$page_count" > lastgood.meta.json
+ printf '{"sha":"%s"}' "$sha" > latest.json
+ cd workers
+ upload_generation() {
+ npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/tar.zst" --file ../lastgood.tar.zst --remote &&
+ npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/pdf" --file "../dist/assets/en/$slug/vyos-documentation.pdf" --remote &&
+ npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/$sha/meta.json" --file ../lastgood.meta.json --remote
+ }
+ if ! (upload_generation || upload_generation); then
+ echo "::error::registry generation upload failed twice β€” REGISTRY STALE (see spec Β§7.1.4)"
+ exit 1
+ fi
+
+ - name: Post-promote probe + auto-rollback (Β§7.1.5)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ CF_ZONE_ID: ${{ vars.CF_ZONE_ID_VYOS_IO }}
+ DOCS_CF_LIVE: ${{ vars.DOCS_CF_LIVE }}
+ run: |
+ set -eu
+ if [ "$DOCS_CF_LIVE" != "true" ]; then
+ echo "DOCS_CF_LIVE=false β€” pre-cutover: docs.vyos.io still serves RTD; skipping production probe"
+ exit 0
+ fi
+ slug='${{ steps.matrix.outputs.slug }}'
+ # Retry loop: the purge above is asynchronous, so an immediate probe can still
+ # observe a stale edge response and trigger a false rollback. Poll up to 6 times
+ # (60s budget) before concluding the promote genuinely failed.
+ got=""
+ for attempt in 1 2 3 4 5 6; do
+ got=$(curl -sI "https://docs.vyos.io/en/$slug/" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-docs-build"{print $2}')
+ if [ "$got" = '${{ github.sha }}' ]; then
+ break
+ fi
+ if [ "$attempt" -lt 6 ]; then
+ echo "::notice::post-promote probe attempt $attempt/6 saw stale edge (got $got) β€” retrying in 10s"
+ sleep 10
+ fi
+ done
+ if [ "$got" != '${{ github.sha }}' ]; then
+ if [ -z '${{ steps.promote.outputs.rollback_id }}' ]; then
+ echo "::error::post-promote probe failed (got $got) on FIRST deploy β€” no prior version to roll back to; investigate manually"
+ exit 1
+ fi
+ echo "::error::post-promote probe failed (got $got) β€” rolling back"
+ cd workers && npx wrangler rollback '${{ steps.promote.outputs.rollback_id }}' \
+ --name '${{ steps.matrix.outputs.worker }}' --yes
+ curl -sf -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
+ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
+ --data '{"hosts":["docs.vyos.io"]}'
+ exit 1
+ fi
+
+ - name: Publish registry pointer (Β§7.1.4)
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN_DOCS }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -eu
+ slug='${{ steps.matrix.outputs.slug }}'
+ sha='${{ github.sha }}'
+ cd workers
+ # Runs only after the post-promote probe/auto-rollback step above has passed (or
+ # was a pre-cutover no-op). Repointing $slug/latest.json is the sole remaining
+ # step of the Β§7.1.4 atomic-publish sequence β€” the sha-scoped generation objects
+ # were already uploaded in PROMOTE. If this fails, the sha-scoped objects landed
+ # under $slug/$sha/ but latest.json still points at the previous generation.
+ publish_pointer() {
+ npx wrangler r2 object put "$REGISTRY_BUCKET/$slug/latest.json" --file ../latest.json --remote
+ }
+ if ! (publish_pointer || publish_pointer); then
+ echo "::error::registry pointer publish failed twice β€” sha-scoped objects landed under $slug/$sha/ but latest.json still points at the previous generation (see spec Β§7.1.4)"
+ exit 1
+ fi
diff --git a/.github/workflows/docs-canary-qa.yml b/.github/workflows/docs-canary-qa.yml
new file mode 100644
index 00000000..7f3be7c0
--- /dev/null
+++ b/.github/workflows/docs-canary-qa.yml
@@ -0,0 +1,47 @@
+name: Docs canary QA (nightly per-entry sweep + parity)
+on:
+ schedule:
+ - cron: "45 2 * * *"
+ workflow_dispatch: {}
+permissions:
+ contents: read
+jobs:
+ qa:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Per-entry sweep (one page + PDF per versions.json entry, Β§3.4 gate c)
+ run: |
+ set -eu
+ # Capture-first (not pipe/process-substitution-into-while): a malformed
+ # versions.json must fail this step loudly. Piping/process-substituting jq
+ # directly into the while loop hides a jq failure from `set -e` (the loop just
+ # sees empty stdin and "passes" with zero iterations) β€” capture the output into
+ # a variable first so a non-zero jq exit trips `set -e` before the loop runs.
+ versions=$(jq -ce '.versions[]' workers/versions.json)
+ fail=0
+ while read -r v; do
+ slug=$(echo "$v" | jq -r .slug); pdf=$(echo "$v" | jq -r '.pdf // empty')
+ if ! python -m scripts.docs_gates.smoke --host docs-next.vyos.io --slug "$slug" \
+ --expect-sha SKIP --access-id '${{ secrets.CF_ACCESS_CLIENT_ID }}' \
+ --access-secret '${{ secrets.CF_ACCESS_CLIENT_SECRET }}' \
+ ${pdf:+--pdf "$pdf"}; then
+ echo "sweep-fail=$slug" | tee -a "$GITHUB_STEP_SUMMARY"
+ fail=1
+ fi
+ done <<< "$versions"
+ exit $fail # a broken advertised version MUST fail nightly QA (Β§3.4 gate c)
+ # --expect-sha SKIP = presence-only assert; implemented in smoke.docs_build_ok (Task 3.2).
+ - name: URL parity vs RTD (pre-cutover truth)
+ run: |
+ # --slugs pinned to CF-built versions: 1.3/1.2 have no RTD sitemaps (Β§15a.5);
+ # legacy parity is the snapshot repo's crawl-inventory job (defense in depth
+ # alongside the matching parity.py DEFAULT_SLUGS)
+ python -m scripts.docs_gates.parity \
+ --sitemap-host docs.vyos.io --probe-host docs-next.vyos.io \
+ --slugs rolling,1.5,1.4 \
+ --access-id '${{ secrets.CF_ACCESS_CLIENT_ID }}' \
+ --access-secret '${{ secrets.CF_ACCESS_CLIENT_SECRET }}'
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with: { name: parity-report, path: parity-report.json }
diff --git a/.github/workflows/docs-preview-approve.yml b/.github/workflows/docs-preview-approve.yml
new file mode 100644
index 00000000..f0fb713b
--- /dev/null
+++ b/.github/workflows/docs-preview-approve.yml
@@ -0,0 +1,35 @@
+name: Docs preview approval record
+on:
+ pull_request_target:
+ types: [labeled, synchronize]
+permissions:
+ statuses: write
+ pull-requests: write
+ issues: write # `gh pr edit --remove-label` mutates labels via the issues API
+jobs:
+ strip-on-push:
+ if: github.event.action == 'synchronize'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Remove preview label on every new push (Β§10 one-label-one-deploy)
+ run: gh pr edit '${{ github.event.pull_request.number }}' --remove-label preview --repo '${{ github.repository }}' || true
+ env: { GH_TOKEN: "${{ github.token }}" }
+ record-approval:
+ if: github.event.action == 'labeled' && github.event.label.name == 'preview'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify labeler permission; record approval on exact head SHA
+ env:
+ GH_TOKEN: "${{ github.token }}"
+ SENDER: "${{ github.event.sender.login }}"
+ run: |
+ set -eu
+ perm=$(gh api "repos/${{ github.repository }}/collaborators/$SENDER/permission" --jq .permission)
+ case "$perm" in admin|write|maintain) ;; *)
+ gh pr edit '${{ github.event.pull_request.number }}' --remove-label preview --repo '${{ github.repository }}'
+ echo "::error::labeler lacks write permission β€” label removed"; exit 1;;
+ esac
+ sha='${{ github.event.pull_request.head.sha }}'
+ gh api -X POST "repos/${{ github.repository }}/statuses/$sha" \
+ -f state=success -f context=docs-preview-approved \
+ -f description="preview approved by $SENDER"
diff --git a/.github/workflows/docs-preview-build.yml b/.github/workflows/docs-preview-build.yml
new file mode 100644
index 00000000..29682979
--- /dev/null
+++ b/.github/workflows/docs-preview-build.yml
@@ -0,0 +1,26 @@
+name: Docs preview build
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, labeled]
+ paths: ["docs/**", "workers/versions.json"]
+permissions:
+ contents: read
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false # untrusted PR code runs in this job; token is read-only but no reason to leave it on disk
+ - name: Build HTML (no PDF in previews, Β§10)
+ run: |
+ pip install -r requirements.txt
+ cd docs && DOCS_VERSION_SLUG=rolling make html
+ - name: Pagefind + package
+ run: |
+ mkdir -p out/en/rolling && cp -r docs/_build/html/. out/en/rolling/
+ npx --yes pagefind --site out/en/rolling
+ echo '${{ github.event.pull_request.number }}' > out/PR_NUMBER
+ echo '${{ github.event.pull_request.head.sha }}' > out/HEAD_SHA
+ - uses: actions/upload-artifact@v4
+ with: { name: docs-preview, path: out/, retention-days: 3 }
diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml
new file mode 100644
index 00000000..384b762f
--- /dev/null
+++ b/.github/workflows/docs-preview-cleanup.yml
@@ -0,0 +1,43 @@
+name: Docs preview cleanup
+on:
+ pull_request_target:
+ types: [closed]
+ schedule:
+ - cron: "17 3 * * *" # nightly sweep (Β§10)
+permissions:
+ contents: read
+ pull-requests: read
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Delete R2 prefixes for closed PRs (bulk, S3 API)
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ AWS_DEFAULT_REGION: auto
+ R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ if [ "${{ github.event_name }}" = "pull_request_target" ]; then
+ aws s3 rm "s3://vyos-docs-previews/pr-${{ github.event.pull_request.number }}/" \
+ --recursive --endpoint-url "$R2_ENDPOINT" --only-show-errors
+ else
+ # nightly: every prefix whose PR is closed. Capture-first (not pipe-into-while):
+ # pipefail + set -e fail the job when the listing itself fails (a piped while
+ # would swallow it via empty stdin), and the per-prefix failure flag survives
+ # (a piped while runs in a subshell, so `failed=1` there would be lost).
+ prefixes=$(aws s3api list-objects-v2 --bucket vyos-docs-previews --delimiter / \
+ --endpoint-url "$R2_ENDPOINT" --query 'CommonPrefixes[].Prefix' --output text \
+ | tr '\t' '\n' | sed -n 's|^pr-\([0-9]*\)/$|\1|p')
+ failed=0
+ while read -r n; do
+ [ -n "$n" ] || continue
+ state=$(gh pr view "$n" --repo '${{ github.repository }}' --json state --jq .state || echo GONE)
+ [ "$state" = "OPEN" ] || aws s3 rm "s3://vyos-docs-previews/pr-$n/" \
+ --recursive --endpoint-url "$R2_ENDPOINT" --only-show-errors \
+ || { echo "::error::SWEEP-FAIL: pr-$n/ not deleted"; failed=1; }
+ done <<< "$prefixes"
+ exit "$failed"
+ fi
diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml
new file mode 100644
index 00000000..3e3de0d3
--- /dev/null
+++ b/.github/workflows/docs-preview-deploy.yml
@@ -0,0 +1,86 @@
+name: Docs preview deploy
+on:
+ workflow_run:
+ workflows: ["Docs preview build"]
+ types: [completed]
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write # label removal + PR comment go through the issues API
+ statuses: read
+ actions: read # cross-run artifact download
+jobs:
+ deploy:
+ if: github.event.workflow_run.conclusion == 'success'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4 # BASE repo code only β€” never PR head (Β§10)
+ - uses: actions/download-artifact@v4
+ with:
+ name: docs-preview
+ path: out
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ github.token }}
+ - name: Authorization gate (approval record + SHA match, Β§10)
+ id: gate
+ env: { GH_TOKEN: "${{ github.token }}" }
+ run: |
+ set -eu
+ # TRUST MODEL (Β§10): out/PR_NUMBER + out/HEAD_SHA come from the UNPRIVILEGED
+ # run and are attacker-controlled. Trusted anchors: the workflow_run event's
+ # head_sha, and the approval record (commit status) written by the privileged
+ # approve workflow. The artifact is accepted only when all three agree.
+ event_sha='${{ github.event.workflow_run.head_sha }}'
+ art_sha=$(cat out/HEAD_SHA)
+ [ "$art_sha" = "$event_sha" ] || { echo "::error::artifact SHA ($art_sha) != workflow_run head_sha ($event_sha)"; exit 1; }
+ # Validate the artifact's claimed PR number BEFORE it's used anywhere β€” it comes
+ # from the unprivileged run and must be a bare digit string.
+ art_pr=$(cat out/PR_NUMBER)
+ case "$art_pr" in
+ ''|*[!0-9]*) echo "::error::artifact PR_NUMBER is not a bare digit string: '$art_pr'"; exit 1 ;;
+ esac
+ # Resolve EVERY PR associated with the trusted head SHA β€” a single commit can be
+ # the head of more than one open PR, so picking just .[0].number is not reliable.
+ # Never trust out/PR_NUMBER for authorization on its own: require it to be a
+ # MEMBER of this trusted list. The membership check happens in bash (below), not
+ # inside a jq filter, so the untrusted artifact value is never interpolated into
+ # jq code.
+ pr_list=$(gh api "repos/${{ github.repository }}/commits/$event_sha/pulls" --jq '.[].number')
+ [ -n "$pr_list" ] || { echo "no open PR for $event_sha"; exit 78; }
+ pr=""
+ while IFS= read -r candidate; do
+ if [ "$candidate" = "$art_pr" ]; then
+ pr="$candidate"
+ break
+ fi
+ done <<< "$pr_list"
+ [ -n "$pr" ] || { echo "::error::artifact PR number ($art_pr) is not among the trusted PRs for $event_sha ($pr_list)"; exit 1; }
+ state=$(gh pr view "$pr" --repo '${{ github.repository }}' --json state,labels)
+ echo "$state" | jq -e '.state == "OPEN"' >/dev/null || { echo "PR closed"; exit 78; }
+ echo "$state" | jq -e '.labels[].name | select(. == "preview")' >/dev/null || { echo "no preview label"; exit 78; }
+ gh api "repos/${{ github.repository }}/commits/$event_sha/statuses" \
+ --jq '.[] | select(.context=="docs-preview-approved" and .state=="success")' | grep -q . \
+ || { echo "::error::no approval record on $event_sha β€” artifact predates/postdates labeling"; exit 1; }
+ echo "pr=$pr" >> "$GITHUB_OUTPUT"
+ - name: Upload to R2 prefix (bulk, S3 API β€” Β§10 MIME via content-type detection)
+ env:
+ AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
+ AWS_DEFAULT_REGION: auto
+ R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
+ run: |
+ set -eu
+ pr='${{ steps.gate.outputs.pr }}'
+ rm -f out/PR_NUMBER out/HEAD_SHA
+ # aws s3 sync = parallel bulk upload + per-file Content-Type guessing.
+ # Sequential per-file `wrangler r2 object put` was rejected: one Node
+ # process per file Γ— thousands of files β‰ˆ runner-timeout territory.
+ aws s3 sync out "s3://vyos-docs-previews/pr-$pr/" \
+ --endpoint-url "$R2_ENDPOINT" --delete --only-show-errors
+ - name: Consume label + comment URL (Β§10)
+ env: { GH_TOKEN: "${{ github.token }}" }
+ run: |
+ pr='${{ steps.gate.outputs.pr }}'
+ gh pr edit "$pr" --remove-label preview --repo '${{ github.repository }}' || true
+ gh pr comment "$pr" --repo '${{ github.repository }}' \
+ --body "πŸ“– Docs preview: https://docs-preview.vyos.io/pr-$pr/en/rolling/ (label consumed β€” re-apply \`preview\` to redeploy after new pushes)"
diff --git a/docs/_static/css/version-picker.css b/docs/_static/css/version-picker.css
new file mode 100644
index 00000000..16ce16c3
--- /dev/null
+++ b/docs/_static/css/version-picker.css
@@ -0,0 +1,11 @@
+.vyos-version-picker-item { list-style: none; }
+#vyos-version-picker { display: flex; align-items: center; gap: .5em; padding: .4em 0; font-size: .9em; }
+#vyos-version-picker select { max-width: 14em; padding: .15em .3em; }
+#vyos-version-picker select:focus { outline: 2px solid #ffae12; outline-offset: 1px; }
+.vyos-pdf-link { font-weight: 600; }
+.vyos-version-banner { position: relative; padding: .6em 2.2em .6em 1em; font-size: .95em; line-height: 1.4; }
+.vyos-banner-dev { background: #e7f2fa; color: #2a6496; }
+.vyos-banner-newer-lts { background: #fff6e5; color: #6b5900; }
+.vyos-banner-eol { background: #fdecea; color: #8a1f11; }
+.vyos-version-banner a { text-decoration: underline; color: inherit; font-weight: 600; }
+.vyos-banner-dismiss { position: absolute; right: .5em; top: .35em; background: none; border: 0; font-size: 1.2em; cursor: pointer; color: inherit; }
diff --git a/docs/_static/js/pagefind-wrapper.js b/docs/_static/js/pagefind-wrapper.js
new file mode 100644
index 00000000..a4acadf8
--- /dev/null
+++ b/docs/_static/js/pagefind-wrapper.js
@@ -0,0 +1,69 @@
+/* Version-scoped Pagefind loader. Derives its base from the current URL so the
+ * same file works on production, canary, and /pr-<n>/ previews (Β§9). */
+(function (window) {
+ 'use strict';
+
+ function basePathFor(pathname) {
+ var m = pathname.match(/^((\/pr-\d+)?\/[a-z]{2}(?:_[A-Z]{2})?\/[^/]+\/)/);
+ if (!m) return null;
+ return { base: m[1], prefix: m[2] || '' };
+ }
+
+ function prefixResultUrl(url, prefix) {
+ return prefix ? prefix + url : url;
+ }
+
+ function assetUrlsFor(base) {
+ return {
+ css: base + 'pagefind/pagefind-ui.css',
+ js: base + 'pagefind/pagefind-ui.js',
+ };
+ }
+
+ function init() {
+ var mount = document.getElementById('vyos-search');
+ if (!mount) return;
+ var ctx = basePathFor(window.location.pathname);
+ if (!ctx) return;
+ var assets = assetUrlsFor(ctx.base);
+ /* Stylesheet first so the UI never renders unstyled. CSS load failure is
+ * cosmetic only β€” no error handler on the link. */
+ var l = document.createElement('link');
+ l.rel = 'stylesheet';
+ l.href = assets.css;
+ document.head.appendChild(l);
+ var s = document.createElement('script');
+ s.src = assets.js;
+ s.onerror = function () {
+ /* Per-version Pagefind assets missing (index not built yet, preview
+ * namespace, transient failure) β€” show a visible notice instead of
+ * leaving #vyos-search a silent empty div. */
+ var p = document.createElement('p');
+ p.className = 'vyos-search-unavailable';
+ p.textContent = 'Search is temporarily unavailable for this version.';
+ mount.appendChild(p);
+ };
+ s.onload = function () {
+ /* global PagefindUI */
+ new window.PagefindUI({
+ element: '#vyos-search',
+ baseUrl: ctx.base,
+ bundlePath: ctx.base + 'pagefind/',
+ processResult: function (result) {
+ result.url = prefixResultUrl(result.url, ctx.prefix);
+ return result;
+ },
+ });
+ };
+ document.head.appendChild(s);
+ }
+
+ window.VyOSSearch = {
+ basePathFor: basePathFor,
+ prefixResultUrl: prefixResultUrl,
+ assetUrlsFor: assetUrlsFor,
+ init: init,
+ };
+ if (typeof document !== 'undefined' && document.addEventListener)
+ document.addEventListener('DOMContentLoaded', init);
+})(window);
diff --git a/docs/_static/js/version-picker.js b/docs/_static/js/version-picker.js
new file mode 100644
index 00000000..f13ad60f
--- /dev/null
+++ b/docs/_static/js/version-picker.js
@@ -0,0 +1,153 @@
+/* VyOS docs version picker + status banner + language scaffold.
+ * Vanilla JS, no dependencies, no build step. Degrades silently when
+ * /versions.json is unreachable (docs stay fully readable). */
+(function (window) {
+ 'use strict';
+
+ // Deliberately does not match /pr-<n>/ preview prefixes β€” previews are single-version,
+ // so the picker has nothing to switch between and stays hidden there by design.
+ function parseLocation(pathname) {
+ var m = pathname.match(/^\/([a-z]{2}(?:_[A-Z]{2})?)\/([^/]+)\/(.*)$/);
+ if (!m) return null;
+ return { lang: m[1], slug: m[2], rest: m[3] };
+ }
+
+ // Contract: versions.json lists versions newest-first, so the first 'lts' entry found
+ // here is the newest LTS β€” callers rely on that ordering rather than comparing versions.
+ function newestLts(manifest) {
+ for (var i = 0; i < manifest.versions.length; i++)
+ if (manifest.versions[i].status === 'lts') return manifest.versions[i].slug;
+ return null;
+ }
+
+ function bannerFor(slug, manifest) {
+ var entry = null, i;
+ for (i = 0; i < manifest.versions.length; i++)
+ if (manifest.versions[i].slug === slug) entry = manifest.versions[i];
+ if (!entry) return null;
+ var newest = newestLts(manifest);
+ if (entry.status === 'dev') return { kind: 'dev', newest: newest };
+ if (entry.status === 'eol') return { kind: 'eol', newest: newest };
+ if (entry.status === 'lts' && newest && newest !== slug)
+ return { kind: 'newer-lts', newest: newest };
+ return null;
+ }
+
+ function targetUrlFor(loc, targetSlug) {
+ return '/' + loc.lang + '/' + targetSlug + '/' + loc.rest;
+ }
+
+ /* Full navigation URL for a version switch: same path on the target version
+ * with the current query string + fragment re-attached, so deep links
+ * (?highlight=…, #section) survive the switch (Β§4 URL-stability contract). */
+ function navUrlFor(loc, targetSlug, search, hash) {
+ return targetUrlFor(loc, targetSlug) + (search || '') + (hash || '');
+ }
+
+ /* ---- DOM layer (no execution at import time) ---- */
+ function bannerText(b, manifest) {
+ if (b.kind === 'dev') return 'You are reading the development (rolling) docs.';
+ if (b.kind === 'eol') return 'This VyOS version is end-of-life; these docs are frozen. See the ' + b.newest + ' (LTS) docs.';
+ return 'A newer LTS (' + b.newest + ') is available.';
+ }
+
+ function init() {
+ var anchor = document.getElementById('vyos-version-picker');
+ if (!anchor) return;
+ var loc = parseLocation(window.location.pathname);
+ if (!loc) return;
+
+ fetch('/versions.json', { headers: { accept: 'application/json' } })
+ .then(function (r) { if (!r.ok) throw new Error('versions.json ' + r.status); return r.json(); })
+ .then(function (manifest) {
+ renderPicker(anchor, loc, manifest);
+ renderLang(anchor, loc, manifest);
+ renderBanner(loc, manifest);
+ })
+ .catch(function () { /* silent degradation (Β§4) */ });
+ }
+
+ function renderPicker(anchor, loc, manifest) {
+ var label = document.createElement('label');
+ label.setAttribute('for', 'vyos-version-select');
+ label.textContent = 'Version: ';
+ var sel = document.createElement('select');
+ sel.id = 'vyos-version-select';
+ manifest.versions.forEach(function (v) {
+ var o = document.createElement('option');
+ o.value = v.slug; o.textContent = v.label; o.selected = v.slug === loc.slug;
+ sel.appendChild(o);
+ });
+ sel.addEventListener('change', function () {
+ var search = window.location.search, hash = window.location.hash;
+ var target = navUrlFor(loc, sel.value, search, hash);
+ var fallback = '/' + loc.lang + '/' + sel.value + '/' + (search || '') + (hash || '');
+ fetch(targetUrlFor(loc, sel.value), { method: 'HEAD' })
+ .then(function (r) {
+ window.location.href = (r.status === 404) ? fallback : target;
+ })
+ .catch(function () { window.location.href = fallback; });
+ });
+ anchor.appendChild(label);
+ anchor.appendChild(sel);
+
+ var entry = manifest.versions.filter(function (v) { return v.slug === loc.slug; })[0];
+ if (entry && entry.pdf) {
+ var a = document.createElement('a');
+ a.href = entry.pdf; a.className = 'vyos-pdf-link'; a.textContent = 'PDF';
+ anchor.appendChild(a);
+ }
+ }
+
+ function renderLang(anchor, loc, manifest) {
+ if (!manifest.languages || manifest.languages.length <= 1) return; // scaffold: hidden while en-only (Β§4)
+ var sel = document.createElement('select');
+ sel.id = 'vyos-lang-select';
+ sel.setAttribute('aria-label', 'Language');
+ manifest.languages.forEach(function (l) {
+ var o = document.createElement('option');
+ o.value = l.code; o.textContent = l.label; o.selected = l.code === loc.lang;
+ sel.appendChild(o);
+ });
+ sel.addEventListener('change', function () {
+ window.location.href = '/' + sel.value + '/' + loc.slug + '/' + loc.rest +
+ window.location.search + window.location.hash;
+ });
+ anchor.appendChild(sel);
+ }
+
+ function renderBanner(loc, manifest) {
+ var b = bannerFor(loc.slug, manifest);
+ if (!b) return;
+ var key = 'vyos-banner-dismissed-' + loc.slug;
+ try { if (window.localStorage.getItem(key)) return; } catch (e) { /* private mode */ }
+ var div = document.createElement('div');
+ div.className = 'vyos-version-banner vyos-banner-' + b.kind;
+ div.setAttribute('role', b.kind === 'eol' ? 'alert' : 'note');
+ var span = document.createElement('span');
+ span.textContent = bannerText(b, manifest);
+ div.appendChild(span);
+ if (b.newest && b.kind !== 'dev') {
+ var link = document.createElement('a');
+ link.href = '/' + loc.lang + '/' + b.newest + '/';
+ link.textContent = ' Switch to ' + b.newest + '.';
+ div.appendChild(link);
+ }
+ var x = document.createElement('button');
+ x.textContent = 'Γ—'; x.className = 'vyos-banner-dismiss';
+ x.setAttribute('aria-label', 'Dismiss');
+ x.addEventListener('click', function () {
+ try { window.localStorage.setItem(key, '1'); } catch (e) { /* ignore */ }
+ div.remove();
+ });
+ div.appendChild(x);
+ document.body.insertBefore(div, document.body.firstChild);
+ }
+
+ window.VyOSVersionPicker = {
+ parseLocation: parseLocation, bannerFor: bannerFor,
+ targetUrlFor: targetUrlFor, navUrlFor: navUrlFor, init: init,
+ };
+ if (typeof document !== 'undefined' && document.addEventListener)
+ document.addEventListener('DOMContentLoaded', init);
+})(window);
diff --git a/docs/_templates/breadcrumbs.html b/docs/_templates/breadcrumbs.html
new file mode 100644
index 00000000..1df3c859
--- /dev/null
+++ b/docs/_templates/breadcrumbs.html
@@ -0,0 +1,5 @@
+{%- extends "sphinx_rtd_theme/breadcrumbs.html" %}
+{% block breadcrumbs_aside %}
+<li class="vyos-version-picker-item"><div id="vyos-version-picker" aria-label="Documentation version"></div></li>
+{{ super() }}
+{% endblock %}
diff --git a/docs/_templates/searchbox.html b/docs/_templates/searchbox.html
new file mode 100644
index 00000000..5fdd378f
--- /dev/null
+++ b/docs/_templates/searchbox.html
@@ -0,0 +1,13 @@
+{#- Full override (sphinx_rtd_theme/layout.html does a plain {% include "searchbox.html" %},
+ not a named block, so there is nothing to {% extends %} + {% block %} here). Only mounts
+ the Pagefind UI on CF-Workers builds (conf.py sets html_context['vyos_cf_build'] from
+ DOCS_VERSION_SLUG) β€” ReadTheDocs runs plain Sphinx with no Pagefind step, so the mount's
+ assets would 404 there. On RTD (and any other non-CF build) fall through to the theme's
+ stock server-side search form via Sphinx's "!" bang-prefix (forces resolution from the
+ theme, bypassing this override, per sphinx/jinja2glue.py). #}
+{% if vyos_cf_build %}
+<div id="vyos-search" role="search"></div>
+<noscript>{% include "!searchbox.html" %}</noscript>
+{% else %}
+{% include "!searchbox.html" %}
+{% endif %}
diff --git a/docs/conf.py b/docs/conf.py
index c3ee0817..cefd1d84 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -125,13 +125,44 @@ html_static_path = ['_static']
html_extra_path = ['_html_extra']
-html_baseurl = 'https://docs.vyos.io/en/rolling/'
+# Version picker + status banner + language scaffold (docs/_static/js/version-picker.js,
+# docs/_static/css/version-picker.css). Appended rather than assigned in case a later
+# addition to this file defines these lists first. Registered unconditionally: it degrades
+# silently on ReadTheDocs (fetch of /versions.json fails there, so nothing renders).
+# globals().get(...) (not a bare `html_js_files` reference guarded by `'html_js_files' in
+# dir()`) avoids a static-analysis F821 (possibly-undefined name) while keeping the same
+# runtime behavior: append to an existing list if one was already defined, else start fresh.
+html_js_files = [*globals().get('html_js_files', []), 'js/version-picker.js']
+html_css_files = [*globals().get('html_css_files', []), 'css/version-picker.css']
+
+# CF-Workers builds inject DOCS_VERSION_SLUG (docs-build.yml); ReadTheDocs builds
+# (until sunset) run plain Sphinx with no Pagefind step, so the Pagefind wrapper
+# script + the searchbox.html override (docs/_templates/searchbox.html) must only
+# activate for CF builds β€” otherwise RTD visitors hit a 404ing search mount.
+_vyos_cf_build = bool(os.environ.get('DOCS_VERSION_SLUG'))
+if _vyos_cf_build:
+ html_js_files = [*html_js_files, 'js/pagefind-wrapper.js']
+
+# Version slug: CF-Workers builds inject DOCS_VERSION_SLUG (docs-build.yml);
+# ReadTheDocs builds (until sunset) fall back to the RTD env vars; local builds
+# default to 'rolling'.
+_docs_slug = os.environ.get('DOCS_VERSION_SLUG')
+if not _docs_slug and os.environ.get('READTHEDOCS_VERSION'):
+ _docs_slug = os.environ['READTHEDOCS_VERSION']
+ if _docs_slug == 'latest':
+ _docs_slug = 'rolling'
+if not _docs_slug:
+ _docs_slug = 'rolling'
+
+html_baseurl = f'https://docs.vyos.io/en/{_docs_slug}/'
_rtd_version_type = os.environ.get('READTHEDOCS_VERSION_TYPE', '')
_github_version = (
os.environ.get('READTHEDOCS_GIT_COMMIT_HASH', 'rolling')
if _rtd_version_type == 'external'
- else os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'rolling')
+ else os.environ.get(
+ 'DOCS_VERSION_BRANCH', os.environ.get('READTHEDOCS_GIT_IDENTIFIER', 'rolling')
+ )
)
html_context = {
@@ -142,6 +173,7 @@ html_context = {
'conf_py_path': '/docs/',
'gtm_id': os.environ.get('GTM_ID', ''),
'cookiebot_id': os.environ.get('COOKIEBOT_ID', ''),
+ 'vyos_cf_build': _vyos_cf_build,
}
# sphinx-sitemap: baseurl already includes /en/rolling/, so skip lang+version
diff --git a/scripts/docs_gates/__init__.py b/scripts/docs_gates/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/scripts/docs_gates/__init__.py
diff --git a/scripts/docs_gates/conftest.py b/scripts/docs_gates/conftest.py
new file mode 100644
index 00000000..38d6127e
--- /dev/null
+++ b/scripts/docs_gates/conftest.py
@@ -0,0 +1,55 @@
+"""Shared pytest fixtures for docs_gates tests.
+
+Provides a real local HTTP server (stdlib http.server, no TLS) used to exercise the
+_NoRedirect opener pattern (parity.py / smoke.py) end-to-end over an actual network
+round trip, rather than only unit-testing the handler class in isolation.
+"""
+from __future__ import annotations
+
+import threading
+from collections.abc import Iterator
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+import pytest
+
+REDIRECT_PATH = "/redirect-me"
+REDIRECT_LOCATION = "https://example.invalid/target"
+
+
+class _RedirectHandler(BaseHTTPRequestHandler):
+ """301+Location for REDIRECT_PATH; 200 for anything else."""
+
+ def do_GET(self) -> None: # noqa: N802 β€” stdlib handler method name
+ self._respond()
+
+ def do_HEAD(self) -> None: # noqa: N802
+ self._respond()
+
+ def _respond(self) -> None:
+ if self.path == REDIRECT_PATH:
+ self.send_response(301)
+ self.send_header("Location", REDIRECT_LOCATION)
+ self.end_headers()
+ else:
+ self.send_response(200)
+ self.send_header("Content-Type", "text/plain")
+ self.end_headers()
+ if self.command == "GET":
+ self.wfile.write(b"ok")
+
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 β€” quiet test output
+ pass
+
+
+@pytest.fixture
+def redirect_http_server() -> Iterator[str]:
+ """Starts the server on 127.0.0.1 (ephemeral port); yields 'host:port'."""
+ server = HTTPServer(("127.0.0.1", 0), _RedirectHandler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield f"127.0.0.1:{server.server_address[1]}"
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join(timeout=5)
diff --git a/scripts/docs_gates/critical-pages.txt b/scripts/docs_gates/critical-pages.txt
new file mode 100644
index 00000000..1bd1a171
--- /dev/null
+++ b/scripts/docs_gates/critical-pages.txt
@@ -0,0 +1,9 @@
+# Paths relative to en/<slug>/ that must exist in every deployable build.
+# Verified 2026-07-10 against a real `sphinx-build -b html docs docs/_build/html-verify`
+# of the `rolling` tree (Python 3.12 venv; coverage.md excluded β€” pre-existing,
+# unrelated CfgcmdList/HTML5Translator crash, not touched by this task).
+index.html
+installation/index.html
+configuration/index.html
+cli.html
+search.html
diff --git a/scripts/docs_gates/gates.py b/scripts/docs_gates/gates.py
new file mode 100644
index 00000000..73f4b23e
--- /dev/null
+++ b/scripts/docs_gates/gates.py
@@ -0,0 +1,93 @@
+"""Deploy-blocking sanity gates (spec Β§7.1).
+
+Gates: file-count vs plan cap (<= 80% of 100k), per-file < 25 MiB, index.html +
+critical-page presence, page-count delta vs previous deploy, canonical URLs must
+start with the https://docs.vyos.io/en/<slug>/ prefix, declared PDF present,
+Pagefind non-empty.
+Exit 0 = deployable; exit 1 = blocked (one line per failed gate on stderr).
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+FILE_CAP = 100_000
+CAP_FRACTION = 0.8
+MAX_FILE = 25 * 1024 * 1024
+COUNT_DELTA_MIN_RATIO = 0.5 # new build must have >= 50% of previous page count
+CANONICAL_RE = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')
+
+
+def _fail(msgs: list[str], msg: str) -> None:
+ msgs.append(msg)
+ print(f"GATE-FAIL: {msg}", file=sys.stderr)
+
+
+def run(artifact: Path, slug: str, versions: Path, previous_meta: Path | None,
+ critical: list[str]) -> int:
+ fails: list[str] = []
+ root = artifact / "en" / slug
+ manifest = json.loads(versions.read_text())
+ entry = next((v for v in manifest["versions"] if v["slug"] == slug), None)
+ if entry is None:
+ _fail(fails, f"slug {slug} not in versions.json")
+ return 1
+
+ files = [p for p in artifact.rglob("*") if p.is_file()]
+ if len(files) > FILE_CAP * CAP_FRACTION:
+ _fail(fails, f"file count {len(files)} > 80% of {FILE_CAP} cap")
+ for p in files:
+ if p.stat().st_size > MAX_FILE:
+ _fail(fails, f"{p.relative_to(artifact)} exceeds 25 MiB")
+
+ for rel in ["index.html", *critical]:
+ if not (root / rel).is_file():
+ _fail(fails, f"critical page missing: en/{slug}/{rel}")
+
+ pagefind = root / "pagefind"
+ if not pagefind.is_dir() or not any(pagefind.iterdir()):
+ _fail(fails, "Pagefind index missing or empty")
+
+ if entry.get("pdf"):
+ expected = artifact / entry["pdf"].lstrip("/")
+ if not expected.is_file():
+ _fail(fails, f"declared PDF missing: {entry['pdf']}")
+
+ pages = [p for p in root.rglob("*.html")]
+ if previous_meta is not None and previous_meta.is_file():
+ prev = json.loads(previous_meta.read_text())
+ if prev.get("page_count") and len(pages) < prev["page_count"] * COUNT_DELTA_MIN_RATIO:
+ _fail(fails, f"page count collapsed: {len(pages)} vs previous {prev['page_count']}")
+
+ want = f"https://docs.vyos.io/en/{slug}/"
+ for p in pages:
+ m = CANONICAL_RE.search(p.read_text(errors="ignore"))
+ if m is None:
+ _fail(fails, f"missing canonical link in en/{slug}/{p.relative_to(root)}")
+ break # one example is enough to block
+ if not m.group(1).startswith(want):
+ _fail(fails, f"bad canonical in en/{slug}/{p.relative_to(root)}: {m.group(1)}")
+ break # one example is enough to block
+
+ return 1 if fails else 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--artifact", type=Path, required=True)
+ ap.add_argument("--slug", required=True)
+ ap.add_argument("--versions", type=Path, required=True)
+ ap.add_argument("--previous-meta", type=Path, default=None)
+ ap.add_argument("--critical-list", type=Path,
+ default=Path("scripts/docs_gates/critical-pages.txt"))
+ a = ap.parse_args()
+ critical = [line.strip() for line in a.critical_list.read_text().splitlines()
+ if line.strip() and not line.startswith("#")]
+ return run(a.artifact, a.slug, a.versions, a.previous_meta, critical)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/docs_gates/parity.py b/scripts/docs_gates/parity.py
new file mode 100644
index 00000000..08225f5a
--- /dev/null
+++ b/scripts/docs_gates/parity.py
@@ -0,0 +1,121 @@
+"""URL-parity corpus + alias assertions (spec Β§11).
+
+Modes:
+ --sitemap-host X pull per-version sitemaps from X (RTD pre-cutover)
+ --probe-host Y probe every URL on Y (canary via Access, or production)
+Fails (exit 1) on any status mismatch (non-200 for corpus rows; wrong
+Location for alias rows).
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+import urllib.request
+from pathlib import Path
+
+SITEMAP_LOC = re.compile(r"<loc>([^<]+)</loc>")
+
+# Sitemap sweep covers only the versions THIS repo builds on CF (rolling/1.5/1.4).
+# 1.3/1.2 have NO RTD sitemaps (spec Β§15a.5) β€” their parity is the legacy snapshot
+# repo's crawl-inventory job. Their alias/PDF redirect rows below stay in scope.
+DEFAULT_SLUGS = "rolling,1.5,1.4"
+
+ALIASES = [("latest", "rolling"), ("stable", "1.5"), ("lts", "1.5"),
+ ("circinus", "1.5"), ("sagitta", "1.4"), ("equuleus", "1.3"), ("crux", "1.2")]
+
+
+def urls_from_sitemap(xml: str) -> list[str]:
+ out = []
+ for loc in SITEMAP_LOC.findall(xml):
+ path = re.sub(r"^https?://[^/]+", "", loc)
+ if path.startswith("/en/"):
+ out.append(path)
+ return out
+
+
+def alias_corpus() -> list[tuple[str, int, str]]:
+ rows = [(f"/en/{a}/", 301, f"/en/{s}/") for a, s in ALIASES]
+ # 1.2 excluded: never had an RTD PDF artifact (Phase-0 finding, spec Β§15a) β€” pdf: null
+ rows += [(f"/_/downloads/en/{s}/pdf/", 301, f"/en/{s}/vyos-documentation.pdf")
+ for s in ["rolling", "1.5", "1.4", "1.3"]]
+ rows.append(("/_/downloads/en/latest/pdf/", 301, "/en/rolling/vyos-documentation.pdf"))
+ return rows
+
+
+class _NoRedirect(urllib.request.HTTPRedirectHandler):
+ """The parity checker must SEE 301s, not follow them (alias assertions)."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
+ return None
+
+
+_OPENER = urllib.request.build_opener(_NoRedirect)
+
+# Overridable in tests (monkeypatched to "http") so fetch() can be exercised end-to-end
+# against a real local http.server instead of requiring TLS for a unit test.
+_SCHEME = "https"
+
+
+def fetch(host: str, path: str, access: tuple[str, str] | None, method: str = "HEAD"):
+ req = urllib.request.Request(f"{_SCHEME}://{host}{path}", method=method)
+ if access:
+ req.add_header("CF-Access-Client-Id", access[0])
+ req.add_header("CF-Access-Client-Secret", access[1])
+ try:
+ with _OPENER.open(req, timeout=30) as r:
+ return r.status, r.headers.get("Location")
+ except urllib.error.HTTPError as e: # 3xx land here with the no-redirect handler
+ return e.code, e.headers.get("Location")
+ except Exception: # noqa: BLE001 β€” DNS blip/timeout fails THIS probe, not the run
+ return 0, None
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--sitemap-host", required=True)
+ ap.add_argument("--probe-host", required=True)
+ ap.add_argument("--slugs", default=DEFAULT_SLUGS)
+ ap.add_argument("--access-id")
+ ap.add_argument("--access-secret")
+ ap.add_argument("--report", type=Path, default=Path("parity-report.json"))
+ a = ap.parse_args()
+ access = (a.access_id, a.access_secret) if a.access_id else None
+ failures: list[dict] = []
+ checked = 0
+
+ for slug in a.slugs.split(","):
+ status, _ = fetch(a.sitemap_host, f"/en/{slug}/sitemap.xml", None, "GET")
+ if status != 200:
+ failures.append({"path": f"/en/{slug}/sitemap.xml", "reason": f"sitemap {status}"})
+ continue
+ try:
+ with urllib.request.urlopen(f"https://{a.sitemap_host}/en/{slug}/sitemap.xml",
+ timeout=30) as r:
+ urls = urls_from_sitemap(r.read().decode())
+ except Exception as e: # noqa: BLE001 β€” record per-slug, keep sweeping; report ALWAYS written
+ failures.append({"path": f"/en/{slug}/sitemap.xml",
+ "reason": f"sitemap fetch error: {e}"})
+ continue
+ for path in urls:
+ checked += 1
+ st, _ = fetch(a.probe_host, path, access)
+ if st != 200:
+ failures.append({"path": path, "reason": f"status {st}"})
+
+ for path, want_status, want_loc in alias_corpus():
+ checked += 1
+ st, loc = fetch(a.probe_host, path, access)
+ if st != want_status or (loc or "") != want_loc:
+ failures.append({"path": path, "reason": f"got {st} β†’ {loc}, want {want_status} β†’ {want_loc}"})
+
+ a.report.write_text(json.dumps({"checked": checked, "failures": failures}, indent=2))
+ for f in failures:
+ print(f"PARITY-FAIL {f['path']}: {f['reason']}", file=sys.stderr)
+ print(f"checked={checked} failures={len(failures)}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/docs_gates/smoke.py b/scripts/docs_gates/smoke.py
new file mode 100644
index 00000000..d25e9a1f
--- /dev/null
+++ b/scripts/docs_gates/smoke.py
@@ -0,0 +1,117 @@
+"""Scoped pre-traffic smoke + post-promote probe (spec Β§7.1 steps 2/5).
+
+Probes ONE version's pages plus apex special paths through a host, presenting a
+CF Access service token. Header contract (Β§3.3): content probes assert
+X-Docs-Build == --expect-sha; apex probes assert X-Apex-Build presence only.
+
+Phase-2 obligation (authorized addition, not in the original spec text): the
+version's index.html probe also asserts the response body carries the
+`#vyos-search` mount div (docs/_templates/searchbox.html), guarding the
+Pagefind gate's silent-degrade failure mode β€” a build that forgot to set
+DOCS_VERSION_SLUG would otherwise ship stock RTD search without CI noticing.
+"""
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import json
+import sys
+import urllib.request
+
+APEX_PATHS = ["/versions.json", "/healthz", "/robots.txt", "/sitemap.xml"]
+SEARCH_MOUNT_MARKER = 'id="vyos-search"'
+
+
+class _NoRedirect(urllib.request.HTTPRedirectHandler):
+ """Probes assert an EXACT status per-path (200 or 404) β€” following a 3xx would
+ silently swap the probed status for whatever the redirect target returns,
+ masking an accidental redirect where a direct 200/404 was expected. Mirrors
+ parity.py's _NoRedirect/_OPENER pattern."""
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
+ return None
+
+
+_OPENER = urllib.request.build_opener(_NoRedirect)
+
+
+@dataclasses.dataclass
+class Probe:
+ path: str
+ expect_status: int
+ assert_docs_build: bool
+ assert_apex_build: bool
+ assert_search_mount: bool = False
+
+
+def probe_plan(slug: str, pdf: str | None, critical: list[str]) -> list[Probe]:
+ plan = [Probe(f"/en/{slug}/{rel}", 200, True, False) for rel in ["index.html", *critical]]
+ plan.append(Probe(f"/en/{slug}/pagefind/pagefind.js", 200, True, False))
+ if pdf:
+ plan.append(Probe(pdf, 200, True, False))
+ plan.append(Probe(f"/en/{slug}/definitely-missing-page-xyz.html", 404, False, False))
+ plan += [Probe(p, 200, False, True) for p in APEX_PATHS]
+ plan[0].assert_search_mount = True # plan[0] is always /en/<slug>/index.html
+ return plan
+
+
+def docs_build_ok(header_value: str | None, expect_sha: str) -> bool:
+ """SKIP sentinel (nightly sweep): header presence only; otherwise exact match."""
+ if expect_sha == "SKIP":
+ return header_value is not None
+ return header_value == expect_sha
+
+
+def search_mount_present(html: str) -> bool:
+ return SEARCH_MOUNT_MARKER in html
+
+
+def run(host: str, slug: str, expect_sha: str, access_id: str, access_secret: str,
+ pdf: str | None, critical: list[str]) -> int:
+ failures = 0
+ for probe in probe_plan(slug, pdf, critical):
+ req = urllib.request.Request(f"https://{host}{probe.path}", method="GET")
+ req.add_header("CF-Access-Client-Id", access_id)
+ req.add_header("CF-Access-Client-Secret", access_secret)
+ try:
+ with _OPENER.open(req, timeout=30) as resp:
+ status, headers, body = resp.status, resp.headers, resp.read()
+ except urllib.error.HTTPError as e: # non-2xx still carries headers
+ status, headers, body = e.code, e.headers, e.read()
+ except Exception as e: # noqa: BLE001 β€” any transport error fails the probe
+ print(f"SMOKE-FAIL {probe.path}: {e}", file=sys.stderr)
+ failures += 1
+ continue
+ ok = status == probe.expect_status
+ if probe.assert_docs_build and not docs_build_ok(headers.get("X-Docs-Build"), expect_sha):
+ ok = False
+ if probe.assert_apex_build and not headers.get("X-Apex-Build"):
+ ok = False
+ if probe.assert_search_mount and not search_mount_present(
+ body.decode("utf-8", errors="replace")):
+ ok = False
+ if not ok:
+ print(f"SMOKE-FAIL {probe.path}: status={status} "
+ f"docs-build={headers.get('X-Docs-Build')}", file=sys.stderr)
+ failures += 1
+ print(json.dumps({"failures": failures}))
+ return 1 if failures else 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--host", required=True)
+ ap.add_argument("--slug", required=True)
+ ap.add_argument("--expect-sha", required=True)
+ ap.add_argument("--access-id", required=True)
+ ap.add_argument("--access-secret", required=True)
+ ap.add_argument("--pdf", default=None)
+ ap.add_argument("--critical-list", default="scripts/docs_gates/critical-pages.txt")
+ a = ap.parse_args()
+ critical = [line.strip() for line in open(a.critical_list).read().splitlines()
+ if line.strip() and not line.startswith("#")]
+ return run(a.host, a.slug, a.expect_sha, a.access_id, a.access_secret, a.pdf, critical)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/docs_gates/test_gates.py b/scripts/docs_gates/test_gates.py
new file mode 100644
index 00000000..a30837ea
--- /dev/null
+++ b/scripts/docs_gates/test_gates.py
@@ -0,0 +1,104 @@
+import json
+from pathlib import Path
+import pytest
+from scripts.docs_gates import gates
+
+
+@pytest.fixture()
+def artifact(tmp_path: Path) -> Path:
+ root = tmp_path / "en" / "rolling"
+ root.mkdir(parents=True)
+ for i in range(50):
+ (root / f"p{i}.html").write_text(
+ f'<html><head><link rel="canonical" href="https://docs.vyos.io/en/rolling/p{i}.html"/></head><body>x</body></html>'
+ )
+ (root / "index.html").write_text(
+ '<html><head><link rel="canonical" href="https://docs.vyos.io/en/rolling/index.html"/></head></html>'
+ )
+ (root / "vyos-documentation.pdf").write_bytes(b"%PDF-1.4 fake")
+ (root / "pagefind").mkdir()
+ (root / "pagefind" / "pagefind.js").write_text("// index")
+ (root / "installation").mkdir()
+ (root / "installation" / "index.html").write_text(
+ '<html><head><link rel="canonical" href="https://docs.vyos.io/en/rolling/installation/index.html"/></head></html>'
+ )
+ return tmp_path
+
+
+def versions_arg(tmp_path: Path) -> Path:
+ """Hermetic stand-in for workers/versions.json β€” writes a fixture-local
+ manifest with the minimal schema the gates consume (mirrors the real
+ file's shape for the rolling entry) so tests never depend on, or break
+ from, edits to the repo file."""
+ p = tmp_path / "versions.json"
+ p.write_text(json.dumps({
+ "schema_version": 2,
+ "default_lang": "en",
+ "default_version": "rolling",
+ "languages": [{"code": "en", "label": "English"}],
+ "versions": [
+ {"slug": "rolling", "label": "Rolling (development)", "status": "dev",
+ "binding": "DOCS_ROLLING", "aliases": ["latest"],
+ "pdf": "/en/rolling/vyos-documentation.pdf"},
+ ],
+ }))
+ return p
+
+
+@pytest.fixture()
+def versions(tmp_path: Path) -> Path:
+ return versions_arg(tmp_path)
+
+
+def test_pass_on_good_artifact(artifact: Path, versions: Path):
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html", "installation/index.html"])
+ assert rc == 0
+
+
+def test_fail_on_missing_critical_page(artifact: Path, versions: Path):
+ (artifact / "en/rolling/installation/index.html").unlink()
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html", "installation/index.html"])
+ assert rc == 1
+
+
+def test_fail_on_count_collapse(artifact: Path, versions: Path, tmp_path: Path):
+ meta = tmp_path / "meta.json"
+ meta.write_text(json.dumps({"sha": "old", "page_count": 5000})) # previous build 100x bigger
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=meta, critical=["index.html"])
+ assert rc == 1
+
+
+def test_fail_on_alias_canonical(artifact: Path, versions: Path):
+ (artifact / "en/rolling/bad.html").write_text(
+ '<html><head><link rel="canonical" href="https://docs.vyos.io/en/latest/bad.html"/></head></html>'
+ )
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html"])
+ assert rc == 1
+
+
+def test_fail_on_missing_canonical(artifact: Path, versions: Path):
+ (artifact / "en/rolling/nocanon.html").write_text(
+ '<html><head></head><body>no canonical link at all</body></html>'
+ )
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html"])
+ assert rc == 1
+
+
+def test_fail_on_oversize_file(artifact: Path, versions: Path):
+ big = artifact / "en/rolling/huge.bin"
+ big.write_bytes(b"\0" * (26 * 1024 * 1024)) # > 25 MiB
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html"])
+ assert rc == 1
+
+
+def test_fail_when_declared_pdf_missing(artifact: Path, versions: Path):
+ (artifact / "en/rolling/vyos-documentation.pdf").unlink()
+ rc = gates.run(artifact=artifact, slug="rolling", versions=versions,
+ previous_meta=None, critical=["index.html"])
+ assert rc == 1
diff --git a/scripts/docs_gates/test_parity.py b/scripts/docs_gates/test_parity.py
new file mode 100644
index 00000000..76057d70
--- /dev/null
+++ b/scripts/docs_gates/test_parity.py
@@ -0,0 +1,72 @@
+import json
+import sys
+import urllib.error
+
+from scripts.docs_gates import parity
+from scripts.docs_gates.conftest import REDIRECT_LOCATION, REDIRECT_PATH
+
+
+def test_sitemap_url_extraction():
+ xml = ('<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
+ "<url><loc>https://docs.vyos.io/en/1.5/a.html</loc></url>"
+ "<url><loc>https://docs.vyos.io/en/1.5/b/</loc></url></urlset>")
+ assert parity.urls_from_sitemap(xml) == ["/en/1.5/a.html", "/en/1.5/b/"]
+
+
+def test_alias_corpus_includes_pdf_and_alias_rows():
+ rows = parity.alias_corpus()
+ assert ("/en/latest/", 301, "/en/rolling/") in rows
+ assert ("/_/downloads/en/1.5/pdf/", 301, "/en/1.5/vyos-documentation.pdf") in rows
+
+
+def test_fetch_never_follows_redirects_unit():
+ # unit-level sanity check on the handler class in isolation (kept alongside the
+ # end-to-end test below, which is what actually proves the opener is wired up)
+ handler = parity._NoRedirect()
+ assert handler.redirect_request(None, None, 301, "Moved", {}, "https://x/") is None
+
+
+def test_fetch_never_follows_redirects(redirect_http_server, monkeypatch):
+ # End-to-end: real local HTTP server returns a 301, exercised through the PUBLIC
+ # fetch() entrypoint (not just the handler class) β€” proves _OPENER is actually
+ # wired into fetch() and surfaces (301, Location) instead of following it.
+ monkeypatch.setattr(parity, "_SCHEME", "http")
+ status, location = parity.fetch(redirect_http_server, REDIRECT_PATH, None, "GET")
+ assert status == 301
+ assert location == REDIRECT_LOCATION
+
+
+def test_default_slugs_scoped_to_cf_built_versions():
+ # 1.3/1.2 have NO RTD sitemaps (spec Β§15a.5); legacy parity belongs to the
+ # snapshot repo's crawl-inventory job, not this sweep
+ assert parity.DEFAULT_SLUGS == "rolling,1.5,1.4"
+
+
+def test_fetch_records_transport_error_as_status_zero(monkeypatch):
+ # a DNS blip / timeout must fail the single probe, not abort the whole run
+ class _Boom:
+ def open(self, req, timeout=None):
+ raise urllib.error.URLError("dns blip")
+
+ monkeypatch.setattr(parity, "_OPENER", _Boom())
+ assert parity.fetch("host.invalid", "/en/rolling/", None) == (0, None)
+
+
+def test_main_always_writes_report_on_transport_errors(tmp_path, monkeypatch):
+ # sitemap status probe says 200, but the body fetch raises mid-sweep:
+ # the run must record per-slug failures, keep going, and STILL write the report
+ report = tmp_path / "parity-report.json"
+ monkeypatch.setattr(parity, "fetch", lambda *a, **k: (200, None))
+
+ def _boom(*a, **k):
+ raise urllib.error.URLError("timed out")
+
+ monkeypatch.setattr(parity.urllib.request, "urlopen", _boom)
+ monkeypatch.setattr(sys, "argv", ["parity", "--sitemap-host", "sitemap.invalid",
+ "--probe-host", "probe.invalid",
+ "--report", str(report)])
+ rc = parity.main()
+ assert rc == 1
+ data = json.loads(report.read_text())
+ assert data["failures"] # report written despite transport errors
+ assert any("sitemap" in f["reason"] for f in data["failures"])
diff --git a/scripts/docs_gates/test_smoke.py b/scripts/docs_gates/test_smoke.py
new file mode 100644
index 00000000..595e6a28
--- /dev/null
+++ b/scripts/docs_gates/test_smoke.py
@@ -0,0 +1,70 @@
+import urllib.error
+import urllib.request
+
+from scripts.docs_gates import smoke
+from scripts.docs_gates.conftest import REDIRECT_LOCATION, REDIRECT_PATH
+
+
+def test_probe_plan_scoped_to_slug():
+ plan = smoke.probe_plan("1.5", pdf="/en/1.5/vyos-documentation.pdf",
+ critical=["index.html", "cli/index.html"])
+ urls = [p.path for p in plan]
+ assert "/en/1.5/index.html" in urls
+ assert "/en/1.5/cli/index.html" in urls
+ assert "/en/1.5/vyos-documentation.pdf" in urls
+ assert "/en/1.5/definitely-missing-page-xyz.html" in urls # 404-status probe
+ assert "/versions.json" in urls and "/healthz" in urls # apex specials
+ assert not any(u.startswith("/en/rolling/") for u in urls) # scoped (Β§7.1.2)
+
+
+def test_assertions_follow_header_contract():
+ plan = smoke.probe_plan("1.5", pdf=None, critical=["index.html"])
+ content = next(p for p in plan if p.path == "/en/1.5/index.html")
+ assert content.expect_status == 200 and content.assert_docs_build is True
+ apex = next(p for p in plan if p.path == "/versions.json")
+ assert apex.assert_docs_build is False and apex.assert_apex_build is True
+ missing = next(p for p in plan if "definitely-missing" in p.path)
+ assert missing.expect_status == 404
+
+
+def test_skip_sentinel_relaxes_sha_to_presence_only():
+ # expect_sha == "SKIP" (nightly sweep, Task 3.5): header must be PRESENT but any value passes
+ assert smoke.docs_build_ok("anything", expect_sha="SKIP") is True
+ assert smoke.docs_build_ok(None, expect_sha="SKIP") is False
+ assert smoke.docs_build_ok("abc", expect_sha="abc") is True
+ assert smoke.docs_build_ok("abc", expect_sha="def") is False
+
+
+# --- Phase-2 obligation (authorized addition, not in the original brief): the
+# index.html probe must assert the CF-built HTML carries the `#vyos-search`
+# mount div, so CI catches a build that silently forgot to set
+# DOCS_VERSION_SLUG (which would ship stock RTD search instead of Pagefind). ---
+
+def test_probe_plan_asserts_search_mount_on_index_only():
+ plan = smoke.probe_plan("1.5", pdf=None, critical=["index.html", "cli/index.html"])
+ index = next(p for p in plan if p.path == "/en/1.5/index.html")
+ assert index.assert_search_mount is True
+ other_content = [p for p in plan if p.path != "/en/1.5/index.html" and p.assert_docs_build]
+ assert other_content and all(p.assert_search_mount is False for p in other_content)
+
+
+# --- CodeRabbit finding: smoke's probe requests must mirror parity.py's _NoRedirect
+# opener β€” an exact-status probe (200/404) that silently followed a 3xx would report
+# whatever the redirect target returns instead of the redirect itself. ---
+
+def test_opener_observes_redirect_directly_not_followed(redirect_http_server):
+ # End-to-end: a real local HTTP server returns a 301, opened through smoke.py's
+ # module-level _OPENER (the exact object `run()` uses) β€” proves it's actually
+ # wired to refuse the redirect, matching run()'s HTTPError-catch handling of 3xx.
+ req = urllib.request.Request(f"http://{redirect_http_server}{REDIRECT_PATH}", method="GET")
+ try:
+ smoke._OPENER.open(req, timeout=5)
+ raise AssertionError("expected HTTPError for a 301 with the no-redirect opener")
+ except urllib.error.HTTPError as e:
+ assert e.code == 301
+ assert e.headers.get("Location") == REDIRECT_LOCATION
+
+
+def test_search_mount_present():
+ assert smoke.search_mount_present('<div id="vyos-search" role="search"></div>') is True
+ assert smoke.search_mount_present('<html><body>no search here</body></html>') is False
diff --git a/workers/.gitignore b/workers/.gitignore
new file mode 100644
index 00000000..ef5b8080
--- /dev/null
+++ b/workers/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.wrangler/
+dist/
diff --git a/workers/PLAN.md b/workers/PLAN.md
new file mode 100644
index 00000000..c64cd382
--- /dev/null
+++ b/workers/PLAN.md
@@ -0,0 +1,5 @@
+# Cloudflare account entitlements (docs.vyos.io)
+
+- Plan: Workers Paid (pay-as-you-go); zone Enterprise on vyos.io ← from Phase-0 item 0.2; update on change
+- Static-assets file cap in force: 100,000/version (Paid) β€” sanity gates assume this (scripts/docs_gates/limits.py)
+- Plan-dependent features NOT assumed by config: Logpush, Bot Management, Observability retention
diff --git a/workers/apex/assets/404.html b/workers/apex/assets/404.html
new file mode 100644
index 00000000..6beae739
--- /dev/null
+++ b/workers/apex/assets/404.html
@@ -0,0 +1,23 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Page not found β€” VyOS Documentation</title>
+<meta name="robots" content="noindex">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 2.5rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ p { line-height: 1.5; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<main>
+ <h1>404</h1>
+ <p>This page doesn't exist (or moved). Try the <a href="/en/rolling/">latest VyOS documentation</a>.</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/assets/503.html b/workers/apex/assets/503.html
new file mode 100644
index 00000000..60fdc6cf
--- /dev/null
+++ b/workers/apex/assets/503.html
@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Service unavailable β€” VyOS Documentation</title>
+<meta name="robots" content="noindex">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 2.5rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ p { line-height: 1.5; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<main>
+ <h1>503</h1>
+ <p>This version of the documentation is temporarily unavailable. Please try again shortly, or visit
+ <a href="https://vyos.io">vyos.io</a> in the meantime.</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/assets/apple-touch-icon.png b/workers/apex/assets/apple-touch-icon.png
new file mode 100644
index 00000000..82b8fb6b
--- /dev/null
+++ b/workers/apex/assets/apple-touch-icon.png
Binary files differ
diff --git a/workers/apex/assets/favicon.ico b/workers/apex/assets/favicon.ico
new file mode 100644
index 00000000..42e8969e
--- /dev/null
+++ b/workers/apex/assets/favicon.ico
Binary files differ
diff --git a/workers/apex/assets/robots.txt b/workers/apex/assets/robots.txt
new file mode 100644
index 00000000..6f35aa9f
--- /dev/null
+++ b/workers/apex/assets/robots.txt
@@ -0,0 +1,3 @@
+User-agent: *
+Allow: /
+Sitemap: https://docs.vyos.io/sitemap.xml
diff --git a/workers/apex/assets/root.html b/workers/apex/assets/root.html
new file mode 100644
index 00000000..4b266fbf
--- /dev/null
+++ b/workers/apex/assets/root.html
@@ -0,0 +1,30 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>VyOS Documentation</title>
+<meta http-equiv="refresh" content="0; url=/en/rolling/">
+<link rel="canonical" href="/en/rolling/">
+<style>
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #16181d; color: #e8e8e8; display: flex; min-height: 100vh;
+ margin: 0; align-items: center; justify-content: center; }
+ main { text-align: center; max-width: 32rem; padding: 2rem; }
+ h1 { font-size: 1.75rem; margin: 0 0 0.5rem; color: #FFAE12; }
+ a { color: #FFAE12; }
+</style>
+</head>
+<body>
+<!--
+ Fallback static shell for "/" β€” the Worker normally issues a 301 to
+ /en/<default_version>/ (see specialPathFor in src/special.ts) before this
+ file is ever reached. Kept as a static asset so the root path degrades to
+ a working, on-brand page (client-side redirect) if it is ever served
+ directly from the ASSETS binding without passing through the Worker.
+-->
+<main>
+ <h1>VyOS Documentation</h1>
+ <p>Redirecting to the <a href="/en/rolling/">latest documentation</a>&hellip;</p>
+</main>
+</body>
+</html>
diff --git a/workers/apex/src/dispatch.ts b/workers/apex/src/dispatch.ts
new file mode 100644
index 00000000..9ad1f15d
--- /dev/null
+++ b/workers/apex/src/dispatch.ts
@@ -0,0 +1,15 @@
+export function resolveVersion(
+ pathname: string,
+ dispatch: Map<string, string>,
+): { slug: string; binding: string } | null {
+ const m = pathname.match(/^\/en\/([^/]+)\//);
+ if (!m) return null;
+ const binding = dispatch.get(m[1]);
+ return binding ? { slug: m[1], binding } : null;
+}
+
+export function bindingGuard(env: Record<string, unknown>, binding: string): Fetcher | null {
+ const b = env[binding];
+ if (b && typeof (b as Fetcher).fetch === "function") return b as Fetcher;
+ return null;
+}
diff --git a/workers/apex/src/index.ts b/workers/apex/src/index.ts
new file mode 100644
index 00000000..278794e2
--- /dev/null
+++ b/workers/apex/src/index.ts
@@ -0,0 +1,159 @@
+import { loadManifest, buildDispatch } from "./manifest";
+import { resolveVersion, bindingGuard } from "./dispatch";
+import { redirectFor } from "./redirects";
+import { specialPathFor } from "./special";
+import { uaVerdict } from "./uagate";
+import policy from "../ua-policy.json";
+
+export interface ApexEnv extends Record<string, unknown> {
+ ASSETS: Fetcher;
+ APEX_BUILD_SHA: string;
+ DOCS_ENV: "production" | "canary";
+ DOCS_KB?: Fetcher;
+ // Β§5 apex PDF fallback β€” R2 bucket holding oversized legacy PDFs excluded from the
+ // content Worker's own asset tree (currently just the 1.3 PDF). Optional so the
+ // binding-guard path (503, not a crash) exercises on envs that omit it.
+ DOCS_PDFS?: R2Bucket;
+}
+
+const manifest = loadManifest();
+const dispatch = buildDispatch(manifest);
+
+// Security headers only β€” safe on content pass-through (never touches
+// Cache-Control or X-Docs-Build, which the content Worker owns).
+function securityHeaders(resp: Response): Response {
+ const out = new Response(resp.body, resp);
+ out.headers.set("X-Content-Type-Options", "nosniff");
+ out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
+ out.headers.set("Content-Security-Policy-Report-Only", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'");
+ return out;
+}
+
+const DEFAULT_CACHE_CLASS = "public, max-age=0, s-maxage=300, must-revalidate";
+
+function apexHeaders(resp: Response, env: ApexEnv, cacheClass: string = DEFAULT_CACHE_CLASS): Response {
+ const out = securityHeaders(resp);
+ out.headers.set("X-Apex-Build", env.APEX_BUILD_SHA);
+ // Β§3.3 cache contract applies to apex-owned responses too. Error responses (4xx/5xx)
+ // must never carry the s-maxage cache class β€” the cache key excludes User-Agent, so a
+ // cached UA-gate 403 or themed 404/503 would poison the edge for every visitor for the
+ // full s-maxage window. Mirrors the branch worker's withDocsHeaders() precedence.
+ // `cacheClass` lets a specific caller (e.g. the Β§5 PDF R2 fallback) apply a
+ // differently-classed success cache-control; canary/error still always win.
+ out.headers.set(
+ "Cache-Control",
+ env.DOCS_ENV === "canary" || out.status >= 400 ? "no-store" : cacheClass,
+ );
+ return out;
+}
+
+async function themed(env: ApexEnv, status: 404 | 503): Promise<Response> {
+ const page = await env.ASSETS.fetch(new Request(`https://apex.internal/${status}.html`));
+ return apexHeaders(new Response(page.body, { status, headers: { "content-type": "text/html; charset=utf-8" } }), env);
+}
+
+export default {
+ async fetch(request: Request, env: ApexEnv): Promise<Response> {
+ const url = new URL(request.url);
+
+ // 1. UA gate β€” production only (Β§3.2.1)
+ if (env.DOCS_ENV === "production") {
+ const v = uaVerdict(request.headers.get("user-agent") ?? "", policy);
+ if (v === "block") return apexHeaders(new Response("Forbidden", { status: 403 }), env);
+ if (v === "log") console.log(JSON.stringify({ event: "ua-log", ua: request.headers.get("user-agent"), path: url.pathname }));
+ }
+
+ // 2. Special paths (Β§3.2.2)
+ const special = await specialPathFor(request, manifest, env as never);
+ if (special) return apexHeaders(special, env);
+
+ // 2b. Legacy PDF R2 fallback (spec Β§5) β€” the 1.3 PDF (29.2 MiB) exceeds the 25 MiB
+ // static-asset cap and is excluded from the content Worker's own build. MUST run
+ // before version dispatch (step 6): the legacy Worker's asset tree lacks this file,
+ // so unconditional dispatch would 404 on the exact path the PDF 301 (Β§3.2.4) targets.
+ const pdfVersion = manifest.versions.find((v) => v.pdf_r2_key && v.pdf === url.pathname);
+ if (pdfVersion) {
+ const bucket = env.DOCS_PDFS;
+ if (!bucket) {
+ console.log(JSON.stringify({ event: "binding-missing", binding: "DOCS_PDFS" }));
+ return themed(env, 503);
+ }
+ let raw: R2ObjectBody | R2Object | null;
+ try {
+ // Forward Range + conditional (If-None-Match/If-Match/If-Modified-Since) headers
+ // straight through to R2 so a resumed download or a client with a fresh cached
+ // copy doesn't have to re-pull the full 29.2 MiB object.
+ raw = await bucket.get(pdfVersion.pdf_r2_key!, {
+ range: request.headers,
+ onlyIf: request.headers,
+ });
+ } catch (e) {
+ console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) }));
+ return themed(env, 503);
+ }
+ if (!raw) return themed(env, 404);
+
+ // Distinct (longer) cache class from apex's default control-response class β€” this
+ // is effectively content, just not content the legacy content Worker can serve.
+ // 304/206 are both <400 so apexHeaders() still applies this class, not "no-store".
+ const pdfCacheClass = "public, max-age=300, s-maxage=600, must-revalidate";
+ const pdfHeaders: Record<string, string> = {
+ etag: raw.httpEtag,
+ "accept-ranges": "bytes",
+ };
+
+ // A satisfied onlyIf precondition (e.g. If-None-Match matched the R2 object's current
+ // ETag) makes R2 hand back a body-less R2Object β€” just the validators, no content.
+ if (!("body" in raw) || !raw.body) {
+ return apexHeaders(new Response(null, { status: 304, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+ const obj = raw as R2ObjectBody;
+ pdfHeaders["content-type"] = "application/pdf";
+
+ // A satisfied Range request β€” R2 echoes the actually-served byte range on `obj.range`;
+ // its absence means either no Range header was sent or R2 served the full object.
+ const range = obj.range;
+ if (range && "offset" in range) {
+ const start = range.offset ?? 0;
+ const length = range.length ?? obj.size - start;
+ pdfHeaders["content-range"] = `bytes ${start}-${start + length - 1}/${obj.size}`;
+ pdfHeaders["content-length"] = String(length);
+ return apexHeaders(new Response(obj.body, { status: 206, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+
+ pdfHeaders["content-length"] = String(obj.size);
+ return apexHeaders(new Response(obj.body, { status: 200, headers: pdfHeaders }), env, pdfCacheClass);
+ }
+
+ // 3+4. Trailing-slash + alias/codename/PDF 301s (Β§3.2.3-4)
+ const redir = redirectFor(url, manifest);
+ if (redir) return apexHeaders(redir, env);
+
+ // 5. /kb seam (Β§3.2.5)
+ if (url.pathname.startsWith("/kb/") || url.pathname === "/kb") {
+ if (env.DOCS_KB) return securityHeaders(await env.DOCS_KB.fetch(request));
+ return themed(env, 404);
+ }
+
+ // 6. Version dispatch (Β§3.2.6)
+ const hit = resolveVersion(url.pathname, dispatch);
+ if (hit) {
+ const fetcher = bindingGuard(env, hit.binding);
+ if (!fetcher) { // 7. runtime binding guard (Β§3.2.7)
+ console.log(JSON.stringify({ event: "binding-missing", binding: hit.binding }));
+ return themed(env, 503);
+ }
+ try {
+ const resp = await fetcher.fetch(request);
+ if (resp.status === 404) return themed(env, 404);
+ return securityHeaders(resp); // Β§3.3: security headers at apex; cache + X-Docs-Build stay content-owned
+ } catch (e) {
+ console.log(JSON.stringify({ event: "binding-error", binding: hit.binding, error: String(e) }));
+ return themed(env, 503);
+ }
+ }
+
+ // 7. Fallback
+ return themed(env, 404);
+ },
+} satisfies ExportedHandler<ApexEnv>;
diff --git a/workers/apex/src/manifest.ts b/workers/apex/src/manifest.ts
new file mode 100644
index 00000000..396f29ed
--- /dev/null
+++ b/workers/apex/src/manifest.ts
@@ -0,0 +1,55 @@
+import raw from "../../versions.json";
+
+export interface VersionEntry {
+ slug: string; label: string;
+ status: "dev" | "lts" | "eol";
+ binding: string; aliases: string[];
+ pdf: string | null;
+ // R2 object key for the apex PDF fallback (spec Β§5) β€” set only on versions whose PDF
+ // exceeds the 25 MiB static-asset cap and is therefore absent from the content
+ // Worker's own asset tree (currently just 1.3). Optional; most versions omit it.
+ pdf_r2_key?: string;
+}
+export interface Manifest {
+ schema_version: number;
+ default_lang: string; default_version: string;
+ languages: { code: string; label: string }[];
+ versions: VersionEntry[];
+}
+
+// Split out from loadManifest() so tests can validate synthetic manifests without
+// touching the real ../../versions.json import (which loadManifest() is hardwired to).
+export function validateManifest(m: Manifest): Manifest {
+ if (m.schema_version !== 2) throw new Error(`versions.json schema_version ${m.schema_version} != 2`);
+ const slugs = new Set<string>();
+ for (const v of m.versions) {
+ if (!/^DOCS_[A-Z0-9_]+$/.test(v.binding)) throw new Error(`bad binding for ${v.slug}`);
+ if (!["dev", "lts", "eol"].includes(v.status)) throw new Error(`bad status for ${v.slug}`);
+ if (slugs.has(v.slug)) throw new Error(`duplicate slug: ${v.slug}`);
+ // pdf_r2_key names an R2 fallback for the exact `pdf` URL β€” a null pdf has no URL
+ // for the fallback to ever be reached at, so the pairing is nonsensical.
+ if (v.pdf_r2_key && v.pdf === null) throw new Error(`pdf_r2_key set but pdf is null for ${v.slug}`);
+ slugs.add(v.slug);
+ }
+ // Separate pass: an alias must not collide with ANY canonical slug (not just an
+ // earlier one), so `slugs` must be fully populated before this check runs.
+ const aliases = new Set<string>();
+ for (const v of m.versions) {
+ for (const alias of v.aliases) {
+ if (slugs.has(alias)) throw new Error(`alias ${alias} (on ${v.slug}) collides with a canonical slug`);
+ if (aliases.has(alias)) throw new Error(`duplicate alias: ${alias}`);
+ aliases.add(alias);
+ }
+ }
+ if (!m.versions.some((v) => v.slug === m.default_version))
+ throw new Error(`default_version ${m.default_version} not in versions[]`);
+ return m;
+}
+
+export function loadManifest(): Manifest {
+ return validateManifest(raw as Manifest);
+}
+
+export function buildDispatch(m: Manifest): Map<string, string> {
+ return new Map(m.versions.map((v) => [v.slug, v.binding]));
+}
diff --git a/workers/apex/src/redirects.ts b/workers/apex/src/redirects.ts
new file mode 100644
index 00000000..835b50f7
--- /dev/null
+++ b/workers/apex/src/redirects.ts
@@ -0,0 +1,38 @@
+import type { Manifest } from "./manifest";
+
+function aliasMap(m: Manifest): Map<string, string> {
+ const map = new Map<string, string>();
+ for (const v of m.versions) for (const a of v.aliases) map.set(a, v.slug);
+ return map;
+}
+
+function r301(pathAndQuery: string): Response {
+ return new Response(null, { status: 301, headers: { Location: pathAndQuery } });
+}
+
+export function redirectFor(url: URL, m: Manifest): Response | null {
+ const { pathname, search } = url; // never touch url.hash β€” fragments don't reach the server
+
+ // RTD PDF URLs: /_/downloads/en/<ver>/pdf/* β†’ /en/<slug>/vyos-documentation.pdf
+ const pdf = pathname.match(/^\/_\/downloads\/en\/([^/]+)\/pdf(?:\/|$)/);
+ if (pdf) {
+ const slug = aliasMap(m).get(pdf[1]) ?? pdf[1];
+ const entry = m.versions.find((v) => v.slug === slug);
+ // pdf: null means no PDF artifact exists for this version β€” don't 301 into a dead-end 404.
+ // The manifest's pdf value is the source of truth (not a hardcoded filename) so a future
+ // R2-fallback path for 1.3 (or any other version) can move the target without a code change.
+ if (entry && entry.pdf !== null) return r301(`${entry.pdf}${search}`);
+ }
+
+ // Alias / codename prefixes: /en/<alias>/* β†’ /en/<slug>/*
+ const seg = pathname.match(/^\/en\/([^/]+)(\/.*)?$/);
+ if (seg) {
+ const [, first, rest = ""] = seg;
+ const target = aliasMap(m).get(first);
+ if (target) return r301(`/en/${target}${rest || "/"}${search}`);
+ // Trailing-slash normalization on bare version roots: /en/<slug> β†’ /en/<slug>/
+ if (rest === "" && m.versions.some((v) => v.slug === first))
+ return r301(`/en/${first}/${search}`);
+ }
+ return null;
+}
diff --git a/workers/apex/src/special.ts b/workers/apex/src/special.ts
new file mode 100644
index 00000000..f1a843f0
--- /dev/null
+++ b/workers/apex/src/special.ts
@@ -0,0 +1,55 @@
+import type { Manifest } from "./manifest";
+import { bindingGuard } from "./dispatch";
+
+// Static assets (404.html, 503.html, robots.txt, favicons, root) live in the apex
+// ASSETS binding; versions.json is served from the imported manifest so the body
+// always matches the dispatch map.
+export async function specialPathFor(
+ request: Request,
+ m: Manifest,
+ env: Record<string, unknown> & { ASSETS: Fetcher },
+): Promise<Response | null> {
+ const url = new URL(request.url);
+ const p = url.pathname;
+
+ if (p === "/") // default-version redirect (Β§3.2.2) β€” query preserved, like alias redirects
+ return new Response(null, { status: 301, headers: { Location: `/en/${m.default_version}/${url.search}` } });
+
+ if (p === "/versions.json")
+ return new Response(JSON.stringify(m), {
+ headers: { "content-type": "application/json; charset=utf-8" },
+ });
+
+ if (p === "/healthz")
+ return new Response(JSON.stringify({ status: "ok", versions: m.versions.length }), {
+ headers: { "content-type": "application/json" },
+ });
+
+ if (p === "/sitemap.xml") {
+ const entries = m.versions
+ .map((v) => `<sitemap><loc>https://docs.vyos.io/en/${v.slug}/sitemap.xml</loc></sitemap>`)
+ .join("");
+ return new Response(
+ `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</sitemapindex>`,
+ { headers: { "content-type": "application/xml" } },
+ );
+ }
+
+ if (p === "/llms.txt") { // direct body, not a redirect (Β§3.2.2)
+ const def = m.versions.find((v) => v.slug === m.default_version)!;
+ const b = bindingGuard(env, def.binding);
+ if (!b) // do NOT fall through β€” /llms.txt with a missing binding is a 503, not a 404
+ return new Response("service unavailable", { status: 503, headers: { "content-type": "text/plain" } });
+ // Forward the ORIGINAL request (method + conditional-GET headers), just retargeted
+ // to the versioned path β€” mirrors the robots.txt/favicon pass-through below.
+ const target = new URL(`/en/${def.slug}/llms.txt`, url);
+ return b.fetch(new Request(target, request));
+ }
+
+ if (p === "/robots.txt" || p === "/favicon.ico" || /^\/apple-touch-icon.*\.png$/.test(p))
+ // Pass the ORIGINAL request through (not a re-synthesized `new Request(url)`) so
+ // conditional-GET headers (If-None-Match / If-Modified-Since) reach ASSETS intact.
+ return env.ASSETS.fetch(request);
+
+ return null;
+}
diff --git a/workers/apex/src/uagate.ts b/workers/apex/src/uagate.ts
new file mode 100644
index 00000000..822a7277
--- /dev/null
+++ b/workers/apex/src/uagate.ts
@@ -0,0 +1,18 @@
+export interface UaPolicy {
+ allow: string[];
+ log: string[];
+ block: string[];
+}
+
+export type UaVerdict = "allow" | "block" | "log";
+
+export function uaVerdict(ua: string, policy: UaPolicy): UaVerdict {
+ const hit = (list: string[]) => list.some((n) => ua.toLowerCase().includes(n.toLowerCase()));
+ // Explicit blocks take precedence β€” a request-controlled UA string that spoofs an
+ // allow-listed substring (e.g. "Googlebot EvilScraper") must not be able to bypass a
+ // block entry just by also matching the allow list.
+ if (hit(policy.block)) return "block";
+ if (hit(policy.allow)) return "allow";
+ if (hit(policy.log)) return "log";
+ return "allow"; // fail-open default
+}
diff --git a/workers/apex/test/dispatch.test.ts b/workers/apex/test/dispatch.test.ts
new file mode 100644
index 00000000..9a61169d
--- /dev/null
+++ b/workers/apex/test/dispatch.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest, buildDispatch } from "../src/manifest";
+import { resolveVersion, bindingGuard } from "../src/dispatch";
+
+const dispatch = buildDispatch(loadManifest());
+
+describe("version dispatch (Β§3.2.6)", () => {
+ it("resolves /en/<slug>/... to its binding, path untouched", () => {
+ expect(resolveVersion("/en/rolling/cli/index.html", dispatch))
+ .toEqual({ slug: "rolling", binding: "DOCS_ROLLING" });
+ expect(resolveVersion("/en/1.5/", dispatch))
+ .toEqual({ slug: "1.5", binding: "DOCS_V15" });
+ });
+ it("returns null for unknown version or non-version paths", () => {
+ expect(resolveVersion("/en/9.9/x", dispatch)).toBeNull();
+ expect(resolveVersion("/kb/article", dispatch)).toBeNull();
+ });
+});
+
+describe("runtime binding guard (Β§3.2.7)", () => {
+ it("returns the fetcher when binding exists", () => {
+ const fake = { fetch: async () => new Response("ok") };
+ expect(bindingGuard({ DOCS_ROLLING: fake } as never, "DOCS_ROLLING")).toBe(fake);
+ });
+ it("returns null (β†’ themed 503) when binding missing or not a Fetcher", () => {
+ expect(bindingGuard({} as never, "DOCS_ROLLING")).toBeNull();
+ expect(bindingGuard({ DOCS_ROLLING: 42 } as never, "DOCS_ROLLING")).toBeNull();
+ });
+});
diff --git a/workers/apex/test/manifest.test.ts b/workers/apex/test/manifest.test.ts
new file mode 100644
index 00000000..5569d081
--- /dev/null
+++ b/workers/apex/test/manifest.test.ts
@@ -0,0 +1,129 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest, buildDispatch, validateManifest, type Manifest } from "../src/manifest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs); import the
+// config as a Vite `?raw` asset instead so the file content is inlined at bundle time.
+// eslint-disable-next-line import/no-unresolved
+import wranglerJsonc from "../wrangler.jsonc?raw";
+// eslint-disable-next-line import/no-unresolved
+import rootHtml from "../assets/root.html?raw";
+
+describe("versions.json v2 manifest (Β§3.4)", () => {
+ it("loads and validates schema_version 2 with 5 versions", () => {
+ const m = loadManifest();
+ expect(m.schema_version).toBe(2);
+ expect(m.versions).toHaveLength(5);
+ expect(m.default_version).toBe("rolling");
+ });
+ it("every version carries a binding; statuses in dev|lts|eol", () => {
+ const m = loadManifest();
+ for (const v of m.versions) {
+ expect(v.binding).toMatch(/^DOCS_[A-Z0-9_]+$/);
+ expect(["dev", "lts", "eol"]).toContain(v.status);
+ }
+ });
+ it("dispatch map: every version slug maps to its binding (shared legacy included)", () => {
+ const m = loadManifest();
+ const d = buildDispatch(m);
+ expect(d.size).toBe(m.versions.length);
+ for (const v of m.versions) {
+ expect(d.get(v.slug)).toBe(v.binding);
+ }
+ // aliases are redirect-layer concerns, never dispatch keys
+ for (const v of m.versions) {
+ for (const a of v.aliases) {
+ expect(d.has(a)).toBe(false);
+ }
+ }
+ });
+ it("only 1.3 carries pdf_r2_key (Β§5 apex PDF R2 fallback)", () => {
+ const m = loadManifest();
+ const withKey = m.versions.filter((v) => v.pdf_r2_key);
+ expect(withKey).toHaveLength(1);
+ expect(withKey[0]).toMatchObject({
+ slug: "1.3",
+ pdf: "/en/1.3/vyos-documentation.pdf",
+ pdf_r2_key: "legacy/1.3/vyos-documentation.pdf",
+ });
+ });
+});
+
+function baseManifest(): Manifest {
+ return {
+ schema_version: 2,
+ default_lang: "en",
+ default_version: "rolling",
+ languages: [{ code: "en", label: "English" }],
+ versions: [
+ { slug: "rolling", label: "Rolling", status: "dev", binding: "DOCS_ROLLING",
+ aliases: ["latest"], pdf: null },
+ { slug: "1.5", label: "1.5", status: "lts", binding: "DOCS_V15",
+ aliases: ["stable", "lts"], pdf: null },
+ ],
+ };
+}
+
+describe("validateManifest β€” duplicate/ambiguous slug + alias rejection", () => {
+ it("rejects a duplicate slug", () => {
+ const m = baseManifest();
+ m.versions.push({ ...m.versions[0], binding: "DOCS_ROLLING2" });
+ expect(() => validateManifest(m)).toThrow(/duplicate slug: rolling/);
+ });
+
+ it("rejects a duplicate alias across two versions", () => {
+ const m = baseManifest();
+ m.versions[1].aliases.push("latest"); // "latest" already aliases rolling
+ expect(() => validateManifest(m)).toThrow(/duplicate alias: latest/);
+ });
+
+ it("rejects an alias that collides with a canonical slug", () => {
+ const m = baseManifest();
+ m.versions[0].aliases.push("1.5"); // "1.5" is a real slug
+ expect(() => validateManifest(m)).toThrow(/alias 1\.5 .* collides with a canonical slug/);
+ });
+
+ it("accepts a well-formed manifest unchanged", () => {
+ const m = baseManifest();
+ const snapshot = structuredClone(m);
+ expect(validateManifest(m)).toBe(m);
+ expect(m).toEqual(snapshot); // validation must not mutate the manifest
+ });
+
+ it("accepts pdf_r2_key when pdf is set", () => {
+ const m = baseManifest();
+ m.versions[1].pdf = "/en/1.5/vyos-documentation.pdf";
+ m.versions[1].pdf_r2_key = "legacy/1.5/vyos-documentation.pdf";
+ expect(validateManifest(m)).toBe(m);
+ });
+
+ it("rejects pdf_r2_key set on a version whose pdf is null (no URL for the fallback to be reached at)", () => {
+ const m = baseManifest();
+ m.versions[0].pdf_r2_key = "legacy/rolling/vyos-documentation.pdf"; // pdf stays null
+ expect(() => validateManifest(m)).toThrow(/pdf_r2_key set but pdf is null for rolling/);
+ });
+});
+
+it("every versions.json binding exists in BOTH apex wrangler envs (Β§3.4 gate a)", () => {
+ const raw = wranglerJsonc.replace(/\/\/.*$/gm, ""); // strip line comments
+ const cfg = JSON.parse(raw);
+ const bindings = new Set(buildDispatch(loadManifest()).values());
+ for (const envName of ["canary", "production"]) {
+ const services = new Set((cfg.env[envName].services as { binding: string }[]).map((s) => s.binding));
+ for (const b of bindings) expect(services, `${envName} missing ${b}`).toContain(b);
+ }
+});
+
+it("root.html's hardcoded default-version references stay congruent with the manifest", () => {
+ // root.html is a static fallback shell (never templated at build time β€” see the
+ // comment in the file) so its `/en/<slug>/` references must be hand-kept in sync
+ // with the manifest's default_version. This is the drift guard: it fails loudly if
+ // someone bumps default_version without also updating the static asset.
+ const expected = `/en/${loadManifest().default_version}/`;
+ // Strip HTML comments first β€” the explanatory comment in root.html mentions the
+ // literal placeholder text "/en/<default_version>/", which is documentation, not
+ // a real markup reference, and must not be asserted against the manifest.
+ const withoutComments = rootHtml.replace(/<!--[\s\S]*?-->/g, "");
+ const refs = withoutComments.match(/\/en\/[^/"'\s]+\//g) ?? [];
+ expect(refs.length).toBeGreaterThan(0);
+ for (const ref of refs) expect(ref).toBe(expected);
+});
diff --git a/workers/apex/test/redirects.test.ts b/workers/apex/test/redirects.test.ts
new file mode 100644
index 00000000..5df03197
--- /dev/null
+++ b/workers/apex/test/redirects.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "vitest";
+import { loadManifest } from "../src/manifest";
+import { redirectFor } from "../src/redirects";
+
+const m = loadManifest();
+const loc = (u: string) => {
+ const r = redirectFor(new URL(u), m);
+ return r ? { status: r.status, location: r.headers.get("Location") } : null;
+};
+
+describe("alias + codename 301s (Β§3.2.4)", () => {
+ it("maps every alias, preserving path + query", () => {
+ expect(loc("https://docs.vyos.io/en/latest/cli/index.html?x=1"))
+ .toEqual({ status: 301, location: "/en/rolling/cli/index.html?x=1" });
+ expect(loc("https://docs.vyos.io/en/stable/")).toEqual({ status: 301, location: "/en/1.5/" });
+ expect(loc("https://docs.vyos.io/en/lts/a")).toEqual({ status: 301, location: "/en/1.5/a" });
+ expect(loc("https://docs.vyos.io/en/circinus/a")).toEqual({ status: 301, location: "/en/1.5/a" });
+ expect(loc("https://docs.vyos.io/en/sagitta/a")).toEqual({ status: 301, location: "/en/1.4/a" });
+ expect(loc("https://docs.vyos.io/en/equuleus/a")).toEqual({ status: 301, location: "/en/1.3/a" });
+ expect(loc("https://docs.vyos.io/en/crux/a")).toEqual({ status: 301, location: "/en/1.2/a" });
+ });
+ it("RTD PDF URLs β†’ PDF path taken from the manifest (source of truth, Β§3.4)", () => {
+ const v15pdf = m.versions.find((v) => v.slug === "1.5")!.pdf;
+ const rollingPdf = m.versions.find((v) => v.slug === "rolling")!.pdf;
+ expect(loc("https://docs.vyos.io/_/downloads/en/1.5/pdf/"))
+ .toEqual({ status: 301, location: v15pdf });
+ expect(loc("https://docs.vyos.io/_/downloads/en/latest/pdf/"))
+ .toEqual({ status: 301, location: rollingPdf });
+ });
+ it("RTD PDF URLs for a pdf:null version β†’ no redirect (no dead-end 301)", () => {
+ expect(loc("https://docs.vyos.io/_/downloads/en/crux/pdf/")).toBeNull();
+ expect(loc("https://docs.vyos.io/_/downloads/en/1.2/pdf/")).toBeNull();
+ });
+ it("RTD PDF URLs preserve the query string, appended to the manifest's pdf value", () => {
+ const v14pdf = m.versions.find((v) => v.slug === "1.4")!.pdf;
+ expect(loc("https://docs.vyos.io/_/downloads/en/sagitta/pdf/?x=1"))
+ .toEqual({ status: 301, location: `${v14pdf}?x=1` });
+ });
+ it("does not match /pdf-notes (only an exact /pdf segment)", () => {
+ expect(loc("https://docs.vyos.io/_/downloads/en/rolling/pdf-notes")).toBeNull();
+ });
+ it("trailing-slash normalization on bare version roots (Β§3.2.3)", () => {
+ expect(loc("https://docs.vyos.io/en/1.5")).toEqual({ status: 301, location: "/en/1.5/" });
+ expect(loc("https://docs.vyos.io/en/rolling?q=1")).toEqual({ status: 301, location: "/en/rolling/?q=1" });
+ });
+ it("never emits a fragment in Location (Β§3.2.6)", () => {
+ const r = loc("https://docs.vyos.io/en/latest/page.html#section");
+ expect(r!.location).not.toContain("#");
+ });
+ it("returns null for canonical paths (no redirect loop)", () => {
+ expect(loc("https://docs.vyos.io/en/rolling/")).toBeNull();
+ expect(loc("https://docs.vyos.io/en/1.5/cli/")).toBeNull();
+ });
+});
diff --git a/workers/apex/test/router.test.ts b/workers/apex/test/router.test.ts
new file mode 100644
index 00000000..3ba1867c
--- /dev/null
+++ b/workers/apex/test/router.test.ts
@@ -0,0 +1,334 @@
+import { describe, it, expect, vi } from "vitest";
+import worker from "../src/index";
+
+function makeEnv(overrides: Record<string, unknown> = {}) {
+ const html = (body: string, status = 200) =>
+ new Response(body, { status, headers: { "content-type": "text/html", "X-Docs-Build": "sha-content" } });
+ const fetcher = (tag: string) => ({
+ fetch: async (req: Request) => {
+ const p = new URL(req.url).pathname;
+ if (p.endsWith("/missing.html")) return html("nope", 404);
+ return html(`${tag}:${p}`);
+ },
+ });
+ return {
+ DOCS_ROLLING: fetcher("rolling"), DOCS_V15: fetcher("v15"),
+ DOCS_V14: fetcher("v14"), DOCS_LEGACY: fetcher("legacy"),
+ ASSETS: { fetch: async () => new Response("<h1>404</h1>", { status: 200, headers: { "content-type": "text/html" } }) },
+ APEX_BUILD_SHA: "apex-sha", DOCS_ENV: "canary",
+ ...overrides,
+ } as never;
+}
+const get = (path: string, env = makeEnv(), ua = "vitest") =>
+ worker.fetch(new Request(`https://docs-next.vyos.io${path}`, { headers: { "user-agent": ua } }), env);
+
+describe("apex router (Β§3.2 order)", () => {
+ it("/ β†’ 301 default version", async () => {
+ const r = await get("/");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/");
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("/ redirect preserves the query string (like alias redirects)", async () => {
+ const r = await get("/?ref=email");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/?ref=email");
+ });
+ it("/versions.json served from manifest with X-Apex-Build", async () => {
+ const r = await get("/versions.json");
+ expect(r.status).toBe(200);
+ const body = await r.json() as { schema_version: number };
+ expect(body.schema_version).toBe(2);
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("dispatches /en/rolling/x to the binding with original path", async () => {
+ const r = await get("/en/rolling/cli/index.html");
+ expect(await r.text()).toBe("rolling:/en/rolling/cli/index.html");
+ expect(r.headers.get("X-Docs-Build")).toBe("sha-content");
+ });
+ it("content responses get apex security headers, content-owned headers untouched (Β§3.3)", async () => {
+ const r = await get("/en/rolling/cli/index.html");
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ expect(r.headers.get("X-Docs-Build")).toBe("sha-content"); // not overwritten
+ expect(r.headers.get("X-Apex-Build")).toBeNull(); // apex build header is apex-paths-only
+ });
+ it("alias 301 before dispatch", async () => {
+ const r = await get("/en/latest/cli/");
+ expect(r.status).toBe(301);
+ expect(r.headers.get("Location")).toBe("/en/rolling/cli/");
+ });
+ it("binding 404 β†’ themed 404 with real 404 status (Β§3.2.7)", async () => {
+ const r = await get("/en/rolling/missing.html");
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ });
+ it("missing binding degrades to themed 503, not a crash", async () => {
+ const env = makeEnv({ DOCS_ROLLING: undefined });
+ const r = await get("/en/rolling/", env);
+ expect(r.status).toBe(503);
+ });
+ it("/kb/* β†’ themed 404 while seam unbound; dispatches when DOCS_KB present", async () => {
+ expect((await get("/kb/article")).status).toBe(404);
+ const env = makeEnv({ DOCS_KB: { fetch: async () => new Response("kb!") } });
+ const r = await get("/kb/article", env);
+ expect(await r.text()).toBe("kb!");
+ // /kb passthrough gets the same security headers as any other content response.
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ });
+ it("unknown path β†’ themed 404 + security headers", async () => {
+ const r = await get("/nope");
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(r.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
+ });
+ it("UA gate blocks only in production env", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+ // policy.block is empty at launch β†’ craft env-independent check via log class:
+ const r = await get("/en/rolling/", prodEnv, "GPTBot/1.0");
+ expect(r.status).toBe(200); // log-only, not blocked
+ });
+ it("apex responses carry Β§3.3 cache headers (no-store canary; revalidate production)", async () => {
+ expect((await get("/versions.json")).headers.get("Cache-Control")).toBe("no-store");
+ const prod = await get("/versions.json", makeEnv({ DOCS_ENV: "production" }));
+ expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ });
+ it("error responses stay no-store in production β€” cache key excludes UA, so a cached 4xx/5xx would poison the edge for everyone", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+
+ // themed 404 (unknown path)
+ const notFound = await get("/nope", prodEnv);
+ expect(notFound.status).toBe(404);
+ expect(notFound.headers.get("Cache-Control")).toBe("no-store");
+
+ // themed 503 (missing runtime binding)
+ const svcUnavailable = await get("/en/rolling/", makeEnv({ DOCS_ENV: "production", DOCS_ROLLING: undefined }));
+ expect(svcUnavailable.status).toBe(503);
+ expect(svcUnavailable.headers.get("Cache-Control")).toBe("no-store");
+
+ // UA-block 403 β€” force a block entry via a policy override, since the shipped
+ // ua-policy.json block list is empty at launch.
+ vi.resetModules();
+ vi.doMock("../ua-policy.json", () => ({
+ default: { allow: [], log: [], block: ["EvilScraper"] },
+ }));
+ try {
+ const { default: freshWorker } = await import("../src/index");
+ const blocked = await freshWorker.fetch(
+ new Request("https://docs-next.vyos.io/en/rolling/", { headers: { "user-agent": "EvilScraper/1.0" } }),
+ prodEnv,
+ );
+ expect(blocked.status).toBe(403);
+ expect(blocked.headers.get("Cache-Control")).toBe("no-store");
+ } finally {
+ vi.doUnmock("../ua-policy.json");
+ vi.resetModules();
+ }
+ });
+ it("/llms.txt with missing default binding β†’ 503, never 404", async () => {
+ const env = makeEnv({ DOCS_ROLLING: undefined });
+ expect((await get("/llms.txt", env)).status).toBe(503);
+ });
+ it("/llms.txt forwards the ORIGINAL request (method + conditional-GET headers preserved)", async () => {
+ let seenMethod: string | null = null;
+ let seenIfNoneMatch: string | null = null;
+ const env = makeEnv({
+ DOCS_ROLLING: {
+ fetch: async (req: Request) => {
+ seenMethod = req.method;
+ seenIfNoneMatch = req.headers.get("if-none-match");
+ return new Response("llms body", { headers: { "content-type": "text/plain" } });
+ },
+ },
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/llms.txt", {
+ method: "HEAD",
+ headers: { "user-agent": "vitest", "if-none-match": '"xyz"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(seenMethod).toBe("HEAD");
+ expect(seenIfNoneMatch).toBe('"xyz"');
+ });
+ it("robots.txt passthrough forwards the ORIGINAL request (conditional-GET headers preserved)", async () => {
+ let seenIfNoneMatch: string | null = null;
+ const env = makeEnv({
+ ASSETS: {
+ fetch: async (req: Request) => {
+ seenIfNoneMatch = req.headers.get("if-none-match");
+ return new Response("User-agent: *", { headers: { "content-type": "text/plain" } });
+ },
+ },
+ });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/robots.txt", {
+ headers: { "user-agent": "vitest", "if-none-match": '"abc123"' },
+ }),
+ env,
+ );
+ expect(r.status).toBe(200);
+ expect(seenIfNoneMatch).toBe('"abc123"');
+ });
+
+ it("UA gate in production tolerates a missing User-Agent header (no crash, fail-open)", async () => {
+ const prodEnv = makeEnv({ DOCS_ENV: "production" });
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/rolling/", { headers: {} }),
+ prodEnv,
+ );
+ expect(r.status).toBe(200);
+ });
+
+ describe("legacy PDF R2 fallback (spec Β§5) β€” runs before version dispatch", () => {
+ // Fake R2Bucket mock following the preview-worker precedent (apex/preview/test/preview.test.ts).
+ // Simulates R2's onlyIf (If-None-Match β†’ body-less R2Object) + range (echoes the
+ // satisfied byte range on `.range`) behavior closely enough to exercise index.ts's
+ // handling of both without needing the real R2 binding.
+ const ETAG = '"pdf-etag-1"';
+ function r2Env(
+ objects: Record<string, { body: string; etag?: string }>,
+ overrides: Record<string, unknown> = {},
+ ) {
+ return makeEnv({
+ DOCS_PDFS: {
+ get: async (key: string, options?: { onlyIf?: Headers; range?: Headers }) => {
+ const hit = objects[key];
+ if (!hit) return null;
+ const etag = hit.etag ?? ETAG;
+ const size = hit.body.length;
+
+ const ifNoneMatch = options?.onlyIf?.get?.("if-none-match");
+ if (ifNoneMatch && ifNoneMatch === etag) {
+ return { httpEtag: etag, size }; // R2Object, no `body` β€” precondition matched
+ }
+
+ const rangeHeader = options?.range?.get?.("range");
+ const m = rangeHeader ? /^bytes=(\d+)-(\d+)$/.exec(rangeHeader) : null;
+ if (m) {
+ const offset = Number(m[1]);
+ const length = Number(m[2]) - offset + 1;
+ return {
+ httpEtag: etag,
+ size,
+ body: hit.body.slice(offset, offset + length),
+ range: { offset, length },
+ };
+ }
+
+ return { httpEtag: etag, size, body: hit.body };
+ },
+ } as unknown as R2Bucket,
+ ...overrides,
+ });
+ }
+
+ it("served-from-R2 200: content-type application/pdf, PDF cache class, X-Apex-Build present, etag + accept-ranges", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("PDF-BYTES");
+ expect(r.headers.get("content-type")).toBe("application/pdf");
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ expect(r.headers.get("X-Apex-Build")).toBe("apex-sha");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("accept-ranges")).toBe("bytes");
+ expect(r.headers.get("content-length")).toBe("9");
+ });
+
+ it("If-None-Match matching R2's etag β†’ 304, no body, etag present, PDF cache class", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } },
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", "if-none-match": ETAG },
+ }),
+ env,
+ );
+ expect(r.status).toBe(304);
+ expect(await r.text()).toBe("");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ expect(r.headers.get("content-type")).toBeNull();
+ });
+
+ it("Range: bytes=0-3 β†’ 206 + Content-Range + partial body, PDF cache class", async () => {
+ const env = r2Env(
+ { "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }, // length 9
+ { DOCS_ENV: "production" },
+ );
+ const r = await worker.fetch(
+ new Request("https://docs-next.vyos.io/en/1.3/vyos-documentation.pdf", {
+ headers: { "user-agent": "vitest", range: "bytes=0-3" },
+ }),
+ env,
+ );
+ expect(r.status).toBe(206);
+ expect(await r.text()).toBe("PDF-");
+ expect(r.headers.get("content-range")).toBe("bytes 0-3/9");
+ expect(r.headers.get("content-length")).toBe("4");
+ expect(r.headers.get("etag")).toBe(ETAG);
+ expect(r.headers.get("Cache-Control")).toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ });
+
+ it("canary env still forces no-store on the PDF response (canary rule wins)", async () => {
+ const env = r2Env({ "legacy/1.3/vyos-documentation.pdf": { body: "PDF-BYTES" } }); // default DOCS_ENV: canary
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("R2 miss β†’ themed 404, no-store, never falls through to DOCS_LEGACY dispatch", async () => {
+ const env = r2Env({}, { DOCS_ENV: "production" }); // bucket present but empty
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(404);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ expect(await r.text()).not.toContain("legacy:"); // not the DOCS_LEGACY fetcher's echoed tag
+ });
+
+ it("R2 get() throwing β†’ themed 503, no-store", async () => {
+ const env = makeEnv({
+ DOCS_ENV: "production",
+ DOCS_PDFS: { get: async () => { throw new Error("R2 unavailable"); } } as unknown as R2Bucket,
+ });
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(503);
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+
+ it("DOCS_PDFS binding missing β†’ themed 503, not a crash", async () => {
+ const env = makeEnv({ DOCS_ENV: "production" }); // no DOCS_PDFS at all
+ const r = await get("/en/1.3/vyos-documentation.pdf", env);
+ expect(r.status).toBe(503);
+ });
+
+ it("other versions' PDF paths never touch DOCS_PDFS β€” fall through to normal dispatch", async () => {
+ // env carries a DOCS_PDFS bucket that would 500 if queried at all, proving the
+ // rolling/1.5/1.4 PDF paths (no pdf_r2_key on those manifest entries) skip it entirely.
+ const env = r2Env(
+ {},
+ {
+ DOCS_ENV: "production",
+ DOCS_PDFS: { get: async () => { throw new Error("must not be called for non-1.3 PDFs"); } } as unknown as R2Bucket,
+ },
+ );
+ const r = await get("/en/rolling/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("rolling:/en/rolling/vyos-documentation.pdf");
+ });
+
+ it("1.2 (pdf: null, no pdf_r2_key) falls through to normal dispatch unaffected", async () => {
+ const env = r2Env({}, { DOCS_ENV: "production" });
+ const r = await get("/en/1.2/vyos-documentation.pdf", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("legacy:/en/1.2/vyos-documentation.pdf");
+ });
+ });
+});
diff --git a/workers/apex/test/uagate.test.ts b/workers/apex/test/uagate.test.ts
new file mode 100644
index 00000000..f4c16c66
--- /dev/null
+++ b/workers/apex/test/uagate.test.ts
@@ -0,0 +1,24 @@
+import { describe, it, expect } from "vitest";
+import { uaVerdict } from "../src/uagate";
+import policy from "../ua-policy.json";
+
+describe("UA gate (Β§3.2.1) β€” ships log-only for AI crawlers", () => {
+ it("search engines always allowed", () => {
+ expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", policy)).toBe("allow");
+ expect(uaVerdict("Mozilla/5.0 (compatible; bingbot/2.0)", policy)).toBe("allow");
+ });
+ it("AI-training crawlers are log-only initially", () => {
+ expect(uaVerdict("GPTBot/1.0", policy)).toBe("log");
+ expect(uaVerdict("CCBot/2.0", policy)).toBe("log");
+ });
+ it("named abusers blocked", () => {
+ expect(uaVerdict("EvilScraper/0.1", { ...policy, block: ["EvilScraper"] })).toBe("block");
+ });
+ it("unknown UA β†’ allow (fail-open for humans)", () => {
+ expect(uaVerdict("Mozilla/5.0 (X11; Linux x86_64) Firefox/128.0", policy)).toBe("allow");
+ });
+ it("block takes precedence over allow on a UA matching both lists", () => {
+ const dualMatch = { ...policy, allow: ["Googlebot"], block: ["Googlebot EvilScraper"] };
+ expect(uaVerdict("Mozilla/5.0 (compatible; Googlebot EvilScraper/1.0)", dualMatch)).toBe("block");
+ });
+});
diff --git a/workers/apex/ua-policy.json b/workers/apex/ua-policy.json
new file mode 100644
index 00000000..4a3534eb
--- /dev/null
+++ b/workers/apex/ua-policy.json
@@ -0,0 +1,5 @@
+{
+ "allow": ["Googlebot", "bingbot", "DuckDuckBot", "YandexBot", "Applebot", "UptimeRobot"],
+ "log": ["GPTBot", "CCBot", "ClaudeBot", "Google-Extended", "Bytespider", "PerplexityBot", "meta-externalagent"],
+ "block": []
+}
diff --git a/workers/apex/wrangler.jsonc b/workers/apex/wrangler.jsonc
new file mode 100644
index 00000000..71c831f3
--- /dev/null
+++ b/workers/apex/wrangler.jsonc
@@ -0,0 +1,39 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-apex",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": { "directory": "assets", "binding": "ASSETS", "run_worker_first": true },
+ "env": {
+ "canary": {
+ // Routes are MANAGED MANUALLY (v4.2 amendment): the account-owned CI token has no
+ // Workers Routes group, and β€” decisive β€” docs.vyos.io is ALREADY orange-cloud, so a
+ // config-managed production route would capture live traffic on first env deploy.
+ // Canary route (docs-next.vyos.io/* β†’ this worker, env canary) is created once by the
+ // operator/dashboard (Task 3.6 step 2c); the production route IS the cutover (Task 6.2).
+ "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "canary" },
+ "services": [
+ { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en-candidate" },
+ { "binding": "DOCS_V15", "service": "vyos-docs-v15-en-candidate" },
+ { "binding": "DOCS_V14", "service": "vyos-docs-v14-en-candidate" },
+ { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy-candidate" }
+ ],
+ // Β§5 apex PDF fallback: same bucket as production β€” there's only one copy of the
+ // (single, immutable) oversized 1.3 PDF artifact, not a per-env candidate/live pair.
+ "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }]
+ },
+ "production": {
+ // production route deliberately ABSENT β€” created manually at cutover (Task 6.2); see canary note above
+ "vars": { "APEX_BUILD_SHA": "dev", "DOCS_ENV": "production" },
+ "services": [
+ { "binding": "DOCS_ROLLING", "service": "vyos-docs-rolling-en" },
+ { "binding": "DOCS_V15", "service": "vyos-docs-v15-en" },
+ { "binding": "DOCS_V14", "service": "vyos-docs-v14-en" },
+ { "binding": "DOCS_LEGACY", "service": "vyos-docs-legacy" }
+ ],
+ "r2_buckets": [{ "binding": "DOCS_PDFS", "bucket_name": "vyos-docs-artifacts" }]
+ }
+ }
+}
diff --git a/workers/bootstrap.sh b/workers/bootstrap.sh
new file mode 100755
index 00000000..a7454f9f
--- /dev/null
+++ b/workers/bootstrap.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# One-time bootstrap: deploy placeholder versions of every service-binding
+# target so the apex Worker (whose config binds all of them) can deploy.
+# Requires CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in the environment.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+mkdir -p ../dist/assets
+for slug in rolling 1.5 1.4 1.3 1.2; do
+ mkdir -p "../dist/assets/en/$slug"
+ printf '<html><body>bootstrap placeholder β€” real build pending</body></html>' \
+ > "../dist/assets/en/$slug/index.html"
+done
+
+deploy() { # config-suffix worker-name
+ for suffix in "" "-candidate"; do
+ npx wrangler deploy --config "branch/wrangler.$1.jsonc" \
+ --name "$2$suffix" \
+ --var DOCS_BUILD_SHA:bootstrap \
+ --var DOCS_ENV:$( [ -n "$suffix" ] && echo canary || echo production )
+ done
+}
+
+npm ci
+deploy rolling vyos-docs-rolling-en
+deploy v15 vyos-docs-v15-en
+deploy v14 vyos-docs-v14-en
+deploy legacy vyos-docs-legacy
+echo "bootstrap complete β€” all 8 binding targets exist; apex can now deploy"
diff --git a/workers/branch/src/index.ts b/workers/branch/src/index.ts
new file mode 100644
index 00000000..d955d166
--- /dev/null
+++ b/workers/branch/src/index.ts
@@ -0,0 +1,44 @@
+export interface Env {
+ ASSETS: Fetcher; // static assets binding
+ DOCS_BUILD_SHA: string; // injected at deploy
+ DOCS_ENV: "production" | "canary";
+}
+
+export type CacheClass = "page" | "asset";
+
+export function classifyPath(path: string): CacheClass {
+ if (path.endsWith(".pdf") || path.includes("/_static/")) return "asset";
+ return "page"; // HTML, versions.json, sitemaps, robots/llms, pagefind index
+}
+
+export function cacheHeaderFor(cls: CacheClass): string {
+ return cls === "asset"
+ ? "public, max-age=300, s-maxage=600, must-revalidate"
+ : "public, max-age=0, s-maxage=300, must-revalidate";
+}
+
+export function withDocsHeaders(
+ resp: Response,
+ path: string,
+ env: Pick<Env, "DOCS_BUILD_SHA" | "DOCS_ENV">,
+): Response {
+ const out = new Response(resp.body, resp);
+ out.headers.set("X-Docs-Build", env.DOCS_BUILD_SHA);
+ out.headers.set(
+ "Cache-Control",
+ // Error responses (4xx/5xx) must never carry the page/asset cache class β€” a
+ // cached 404 would poison the edge for the full s-maxage window.
+ env.DOCS_ENV === "canary" || out.status >= 400
+ ? "no-store"
+ : cacheHeaderFor(classifyPath(path)),
+ );
+ return out;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise<Response> {
+ const url = new URL(request.url);
+ const resp = await env.ASSETS.fetch(request);
+ return withDocsHeaders(resp, url.pathname, env);
+ },
+} satisfies ExportedHandler<Env>;
diff --git a/workers/branch/test/content.test.ts b/workers/branch/test/content.test.ts
new file mode 100644
index 00000000..fcf068da
--- /dev/null
+++ b/workers/branch/test/content.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from "vitest";
+import worker, { classifyPath, cacheHeaderFor, withDocsHeaders, type Env } from "../src/index";
+
+describe("cache classes (Β§3.3)", () => {
+ it("HTML + config class β†’ max-age=0, s-maxage=300", () => {
+ for (const p of ["/en/rolling/index.html", "/en/rolling/versions.json",
+ "/en/rolling/sitemap.xml", "/en/rolling/pagefind/pagefind.js"]) {
+ expect(cacheHeaderFor(classifyPath(p)))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ }
+ });
+ it("PDF + _static class β†’ max-age=300, s-maxage=600", () => {
+ for (const p of ["/en/rolling/vyos-documentation.pdf", "/en/rolling/_static/css/theme.css"]) {
+ expect(cacheHeaderFor(classifyPath(p)))
+ .toBe("public, max-age=300, s-maxage=600, must-revalidate");
+ }
+ });
+});
+
+describe("response headers", () => {
+ it("adds X-Docs-Build and cache-control; canary forces no-store", () => {
+ const base = new Response("ok", { headers: { "content-type": "text/html" } });
+ const prod = withDocsHeaders(base, "/en/rolling/index.html",
+ { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "production" });
+ expect(prod.headers.get("X-Docs-Build")).toBe("abc123");
+ expect(prod.headers.get("Cache-Control")).toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ const canary = withDocsHeaders(base, "/en/rolling/index.html",
+ { DOCS_BUILD_SHA: "abc123", DOCS_ENV: "canary" });
+ expect(canary.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
+
+describe("default fetch entrypoint", () => {
+ const makeEnv = (docsEnv: Env["DOCS_ENV"], seen: string[]): Env => ({
+ ASSETS: {
+ fetch: async (req: Request) => {
+ seen.push(req.url);
+ return new Response("<html>", { headers: { "content-type": "text/html" } });
+ },
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: docsEnv,
+ });
+
+ it("serves assets verbatim with docs headers; path is byte-stable", async () => {
+ const seen: string[] = [];
+ const url = "https://docs.vyos.io/en/rolling/index.html";
+ const resp = await worker.fetch(new Request(url), makeEnv("production", seen));
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ expect(resp.headers.get("Cache-Control"))
+ .toBe("public, max-age=0, s-maxage=300, must-revalidate");
+ expect(seen).toEqual([url]); // original request URL reached ASSETS unmodified
+ });
+
+ it("canary env forces no-store on the fetch path too", async () => {
+ const seen: string[] = [];
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/index.html"),
+ makeEnv("canary", seen),
+ );
+ expect(resp.headers.get("Cache-Control")).toBe("no-store");
+ expect(resp.headers.get("X-Docs-Build")).toBe("testsha");
+ });
+
+ it("4xx/5xx responses are never cached, even in production", async () => {
+ const env: Env = {
+ ASSETS: {
+ fetch: async () => new Response("nope", { status: 404, headers: { "content-type": "text/html" } }),
+ } as unknown as Fetcher,
+ DOCS_BUILD_SHA: "testsha",
+ DOCS_ENV: "production",
+ };
+ const resp = await worker.fetch(
+ new Request("https://docs.vyos.io/en/rolling/missing.html"),
+ env,
+ );
+ expect(resp.status).toBe(404);
+ expect(resp.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
diff --git a/workers/branch/wrangler.legacy.jsonc b/workers/branch/wrangler.legacy.jsonc
new file mode 100644
index 00000000..fd036ec5
--- /dev/null
+++ b/workers/branch/wrangler.legacy.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-legacy",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "auto-trailing-slash",
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.rolling.jsonc b/workers/branch/wrangler.rolling.jsonc
new file mode 100644
index 00000000..4595a06a
--- /dev/null
+++ b/workers/branch/wrangler.rolling.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-rolling-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "auto-trailing-slash",
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.v14.jsonc b/workers/branch/wrangler.v14.jsonc
new file mode 100644
index 00000000..2c7310aa
--- /dev/null
+++ b/workers/branch/wrangler.v14.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-v14-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "auto-trailing-slash",
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/branch/wrangler.v15.jsonc b/workers/branch/wrangler.v15.jsonc
new file mode 100644
index 00000000..45098aee
--- /dev/null
+++ b/workers/branch/wrangler.v15.jsonc
@@ -0,0 +1,16 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-v15-en",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ "assets": {
+ "directory": "../../dist/assets", // populated by CI (Task 3.2); html_handling per Β§3.2.3
+ "binding": "ASSETS",
+ "html_handling": "auto-trailing-slash",
+ "not_found_handling": "404-page",
+ "run_worker_first": true
+ },
+ "vars": { "DOCS_BUILD_SHA": "dev", "DOCS_ENV": "production" }
+}
diff --git a/workers/matrix.json b/workers/matrix.json
new file mode 100644
index 00000000..31ece863
--- /dev/null
+++ b/workers/matrix.json
@@ -0,0 +1,5 @@
+{
+ "rolling": { "worker": "vyos-docs-rolling-en", "slug": "rolling" },
+ "circinus": { "worker": "vyos-docs-v15-en", "slug": "1.5" },
+ "sagitta": { "worker": "vyos-docs-v14-en", "slug": "1.4" }
+}
diff --git a/workers/package-lock.json b/workers/package-lock.json
new file mode 100644
index 00000000..3840ab19
--- /dev/null
+++ b/workers/package-lock.json
@@ -0,0 +1,3921 @@
+{
+ "name": "vyos-docs-workers",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "vyos-docs-workers",
+ "devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.8.19",
+ "typescript": "^5.5.0",
+ "vitest": "~3.2.0",
+ "wrangler": "^4.34.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
+ "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers": {
+ "version": "0.8.71",
+ "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.71.tgz",
+ "integrity": "sha512-keu2HCLQfRNwbmLBCDXJgCFpANTaYnQpE01fBOo4CNwiWHUT7SZGN7w64RKiSWRHyYppStXBuE5Ng7F42+flpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "birpc": "0.2.14",
+ "cjs-module-lexer": "^1.2.3",
+ "devalue": "^5.3.2",
+ "miniflare": "4.20250906.0",
+ "semver": "^7.7.1",
+ "wrangler": "4.35.0",
+ "zod": "^3.22.3"
+ },
+ "peerDependencies": {
+ "@vitest/runner": "2.0.x - 3.2.x",
+ "@vitest/snapshot": "2.0.x - 3.2.x",
+ "vitest": "2.0.x - 3.2.x"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz",
+ "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "mime": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.7.3.tgz",
+ "integrity": "sha512-tsQQagBKjvpd9baa6nWVIv399ejiqcrUBBW6SZx6Z22+ymm+Odv5+cFimyuCsD/fC1fQTwfRmwXBNpzvHSeGCw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.21",
+ "workerd": "^1.20250828.1"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
+ "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
+ "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
+ "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/android-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
+ "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
+ "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
+ "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
+ "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
+ "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
+ "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
+ "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
+ "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
+ "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
+ "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
+ "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
+ "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
+ "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/linux-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
+ "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
+ "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
+ "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
+ "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
+ "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
+ "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
+ "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
+ "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/@esbuild/win32-x64": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
+ "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/esbuild": {
+ "version": "0.25.4",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
+ "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.4",
+ "@esbuild/android-arm": "0.25.4",
+ "@esbuild/android-arm64": "0.25.4",
+ "@esbuild/android-x64": "0.25.4",
+ "@esbuild/darwin-arm64": "0.25.4",
+ "@esbuild/darwin-x64": "0.25.4",
+ "@esbuild/freebsd-arm64": "0.25.4",
+ "@esbuild/freebsd-x64": "0.25.4",
+ "@esbuild/linux-arm": "0.25.4",
+ "@esbuild/linux-arm64": "0.25.4",
+ "@esbuild/linux-ia32": "0.25.4",
+ "@esbuild/linux-loong64": "0.25.4",
+ "@esbuild/linux-mips64el": "0.25.4",
+ "@esbuild/linux-ppc64": "0.25.4",
+ "@esbuild/linux-riscv64": "0.25.4",
+ "@esbuild/linux-s390x": "0.25.4",
+ "@esbuild/linux-x64": "0.25.4",
+ "@esbuild/netbsd-arm64": "0.25.4",
+ "@esbuild/netbsd-x64": "0.25.4",
+ "@esbuild/openbsd-arm64": "0.25.4",
+ "@esbuild/openbsd-x64": "0.25.4",
+ "@esbuild/sunos-x64": "0.25.4",
+ "@esbuild/win32-arm64": "0.25.4",
+ "@esbuild/win32-ia32": "0.25.4",
+ "@esbuild/win32-x64": "0.25.4"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/unenv": {
+ "version": "2.0.0-rc.21",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.21.tgz",
+ "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "defu": "^6.1.4",
+ "exsolve": "^1.0.7",
+ "ohash": "^2.0.11",
+ "pathe": "^2.0.3",
+ "ufo": "^1.6.1"
+ }
+ },
+ "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": {
+ "version": "4.35.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.35.0.tgz",
+ "integrity": "sha512-HbyXtbrh4Fi3mU8ussY85tVdQ74qpVS1vctUgaPc+bPrXBTqfDLkZ6VRtHAVF/eBhz4SFmhJtCQpN1caY2Ak8A==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.4.0",
+ "@cloudflare/unenv-preset": "2.7.3",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.25.4",
+ "miniflare": "4.20250906.0",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.21",
+ "workerd": "1.20250906.0"
+ },
+ "bin": {
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^4.20250906.0"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250906.0.tgz",
+ "integrity": "sha512-E+X/YYH9BmX0ew2j/mAWFif2z05NMNuhCTlNYEGLkqMe99K15UewBqajL9pMcMUKxylnlrEoK3VNxl33DkbnPA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250906.0.tgz",
+ "integrity": "sha512-X5apsZ1SFW4FYTM19ISHf8005FJMPfrcf4U5rO0tdj+TeJgQgXuZ57IG0WeW7SpLVeBo8hM6WC8CovZh41AfnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250906.0.tgz",
+ "integrity": "sha512-rlKzWgsLnlQ5Nt9W69YBJKcmTmZbOGu0edUsenXPmc6wzULUxoQpi7ZE9k3TfTonJx4WoQsQlzCUamRYFsX+0Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250906.0.tgz",
+ "integrity": "sha512-DdedhiQ+SeLzpg7BpcLrIPEZ33QKioJQ1wvL4X7nuLzEB9rWzS37NNNahQzc1+44rhG4fyiHbXBPOeox4B9XVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250906.0.tgz",
+ "integrity": "sha512-Q8Qjfs8jGVILnZL6vUpQ90q/8MTCYaGR3d1LGxZMBqte8Vr7xF3KFHPEy7tFs0j0mMjnqCYzlofmPNY+9ZaDRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+ "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@poppinss/colors": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
+ "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^4.1.5"
+ }
+ },
+ "node_modules/@poppinss/dumper": {
+ "version": "0.6.5",
+ "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
+ "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@sindresorhus/is": "^7.0.2",
+ "supports-color": "^10.0.0"
+ }
+ },
+ "node_modules/@poppinss/exception": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
+ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sindresorhus/is": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
+ "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/is?sponsor=1"
+ }
+ },
+ "node_modules/@speed-highlight/core": {
+ "version": "1.2.17",
+ "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
+ "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitest/expect": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+ "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+ "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "3.2.7",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.17"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+ "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+ "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "3.2.7",
+ "pathe": "^2.0.3",
+ "strip-literal": "^3.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+ "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+ "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^4.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+ "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "3.2.7",
+ "loupe": "^3.1.4",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.14.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
+ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
+ "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "0.2.14",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz",
+ "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/blake3-wasm": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
+ "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/chai": {
+ "version": "5.3.3",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^2.0.1",
+ "check-error": "^2.1.1",
+ "deep-eql": "^5.0.1",
+ "loupe": "^3.1.0",
+ "pathval": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+ "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
+ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-eql": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/defu": {
+ "version": "6.1.7",
+ "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
+ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/error-stack-parser-es": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
+ "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/exit-hook": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
+ "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/exsolve": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
+ "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/glob-to-regexp": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+ "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+ "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mime": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
+ "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/miniflare": {
+ "version": "4.20250906.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250906.0.tgz",
+ "integrity": "sha512-T/RWn1sa0ien80s6NjU+Un/tj12gR6wqScZoiLeMJDD4/fK0UXfnbWXJDubnUED8Xjm7RPQ5ESYdE+mhPmMtuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "acorn": "8.14.0",
+ "acorn-walk": "8.3.2",
+ "exit-hook": "2.2.1",
+ "glob-to-regexp": "0.4.1",
+ "sharp": "^0.33.5",
+ "stoppable": "1.1.0",
+ "undici": "^7.10.0",
+ "workerd": "1.20250906.0",
+ "ws": "8.18.0",
+ "youch": "4.1.0-beta.10",
+ "zod": "3.22.3"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/miniflare/node_modules/zod": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz",
+ "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/ohash": {
+ "version": "2.0.11",
+ "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
+ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.16"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/stoppable": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz",
+ "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+ "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
+ "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+ "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinypool": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+ "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/unenv": {
+ "version": "2.0.0-rc.24",
+ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
+ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pathe": "^2.0.3"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "3.2.7",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+ "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/expect": "3.2.7",
+ "@vitest/mocker": "3.2.7",
+ "@vitest/pretty-format": "^3.2.7",
+ "@vitest/runner": "3.2.7",
+ "@vitest/snapshot": "3.2.7",
+ "@vitest/spy": "3.2.7",
+ "@vitest/utils": "3.2.7",
+ "chai": "^5.2.0",
+ "debug": "^4.4.1",
+ "expect-type": "^1.2.1",
+ "magic-string": "^0.30.17",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.2",
+ "std-env": "^3.9.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.2",
+ "tinyglobby": "^0.2.14",
+ "tinypool": "^1.1.1",
+ "tinyrainbow": "^2.0.0",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+ "vite-node": "3.2.4",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/debug": "^4.1.12",
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@vitest/browser": "3.2.7",
+ "@vitest/ui": "3.2.7",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/debug": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/workerd": {
+ "version": "1.20250906.0",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250906.0.tgz",
+ "integrity": "sha512-ryVyEaqXPPsr/AxccRmYZZmDAkfQVjhfRqrNTlEeN8aftBk6Ca1u7/VqmfOayjCXrA+O547TauebU+J3IpvFXw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20250906.0",
+ "@cloudflare/workerd-darwin-arm64": "1.20250906.0",
+ "@cloudflare/workerd-linux-64": "1.20250906.0",
+ "@cloudflare/workerd-linux-arm64": "1.20250906.0",
+ "@cloudflare/workerd-windows-64": "1.20250906.0"
+ }
+ },
+ "node_modules/wrangler": {
+ "version": "4.109.0",
+ "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.109.0.tgz",
+ "integrity": "sha512-Ev6iIBNnRwvYeJv8B7377YsnY+PxEznHmz3r33cWgP+6A5HNqqqJhChBaVUWxxs0uQPKuYBy8tqrfyd7o8WmBg==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "dependencies": {
+ "@cloudflare/kv-asset-handler": "0.5.0",
+ "@cloudflare/unenv-preset": "2.16.1",
+ "blake3-wasm": "2.1.5",
+ "esbuild": "0.28.1",
+ "miniflare": "4.20260708.0",
+ "path-to-regexp": "6.3.0",
+ "unenv": "2.0.0-rc.24",
+ "workerd": "1.20260708.1"
+ },
+ "bin": {
+ "cf-wrangler": "bin/cf-wrangler.js",
+ "wrangler": "bin/wrangler.js",
+ "wrangler2": "bin/wrangler.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.3"
+ },
+ "peerDependencies": {
+ "@cloudflare/workers-types": "^5.20260708.1"
+ },
+ "peerDependenciesMeta": {
+ "@cloudflare/workers-types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/unenv-preset": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
+ "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
+ "dev": true,
+ "license": "MIT OR Apache-2.0",
+ "peerDependencies": {
+ "unenv": "2.0.0-rc.24",
+ "workerd": ">1.20260305.0 <2.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "workerd": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260708.1.tgz",
+ "integrity": "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260708.1.tgz",
+ "integrity": "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260708.1.tgz",
+ "integrity": "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260708.1.tgz",
+ "integrity": "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260708.1.tgz",
+ "integrity": "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.7.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/wrangler/node_modules/miniflare": {
+ "version": "4.20260708.0",
+ "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.0.tgz",
+ "integrity": "sha512-ut32kKKTyZIoXhbtteDAMOwTVx9aCeehMeKb1xmJs4RLdBrF5flSZD1MOpWqmJycIV7YBHjcaZcVHLMr/g5RZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "0.8.1",
+ "sharp": "0.34.5",
+ "undici": "7.28.0",
+ "workerd": "1.20260708.1",
+ "ws": "8.21.0",
+ "youch": "4.1.0-beta.10"
+ },
+ "bin": {
+ "miniflare": "bootstrap.js"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/wrangler/node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/wrangler/node_modules/workerd": {
+ "version": "1.20260708.1",
+ "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260708.1.tgz",
+ "integrity": "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "workerd": "bin/workerd"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "optionalDependencies": {
+ "@cloudflare/workerd-darwin-64": "1.20260708.1",
+ "@cloudflare/workerd-darwin-arm64": "1.20260708.1",
+ "@cloudflare/workerd-linux-64": "1.20260708.1",
+ "@cloudflare/workerd-linux-arm64": "1.20260708.1",
+ "@cloudflare/workerd-windows-64": "1.20260708.1"
+ }
+ },
+ "node_modules/wrangler/node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
+ "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/youch": {
+ "version": "4.1.0-beta.10",
+ "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
+ "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/colors": "^4.1.5",
+ "@poppinss/dumper": "^0.6.4",
+ "@speed-highlight/core": "^1.2.7",
+ "cookie": "^1.0.2",
+ "youch-core": "^0.3.3"
+ }
+ },
+ "node_modules/youch-core": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
+ "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@poppinss/exception": "^1.2.2",
+ "error-stack-parser-es": "^1.0.5"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/workers/package.json b/workers/package.json
new file mode 100644
index 00000000..65082cb0
--- /dev/null
+++ b/workers/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "vyos-docs-workers",
+ "private": true,
+ "type": "module",
+ "engines": { "node": ">=20" },
+ "scripts": {
+ "test": "vitest run",
+ "test:watch": "vitest"
+ },
+ "devDependencies": {
+ "@cloudflare/vitest-pool-workers": "^0.8.19",
+ "typescript": "^5.5.0",
+ "vitest": "~3.2.0",
+ "wrangler": "^4.34.0"
+ }
+}
diff --git a/workers/picker-test/pagefind-wrapper.test.ts b/workers/picker-test/pagefind-wrapper.test.ts
new file mode 100644
index 00000000..75e8be50
--- /dev/null
+++ b/workers/picker-test/pagefind-wrapper.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in picker.test.ts and apex/test/manifest.test.ts; import as Vite `?raw` asset
+// instead so content is inlined at bundle time β€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/pagefind-wrapper.js?raw";
+
+const ns: Record<string, unknown> = {};
+new Function("window", src)(ns as never);
+const W = (ns as never as { VyOSSearch: Record<string, CallableFunction> }).VyOSSearch;
+
+describe("basePathFor (Β§9)", () => {
+ it("production/canary version path", () => {
+ expect(W.basePathFor("/en/rolling/search.html"))
+ .toEqual({ base: "/en/rolling/", prefix: "" });
+ });
+ it("PR preview path keeps /pr-<n>/ prefix and reports it", () => {
+ expect(W.basePathFor("/pr-42/en/1.5/search.html"))
+ .toEqual({ base: "/pr-42/en/1.5/", prefix: "/pr-42" });
+ });
+ it("prefixes result URLs in previews", () => {
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "/pr-42")).toBe("/pr-42/en/1.5/cli/index.html");
+ expect(W.prefixResultUrl("/en/1.5/cli/index.html", "")).toBe("/en/1.5/cli/index.html");
+ });
+});
+
+describe("assetUrlsFor (css + js pair under the version base)", () => {
+ it("production/canary base", () => {
+ expect(W.assetUrlsFor("/en/rolling/")).toEqual({
+ css: "/en/rolling/pagefind/pagefind-ui.css",
+ js: "/en/rolling/pagefind/pagefind-ui.js",
+ });
+ });
+ it("PR preview base keeps the /pr-<n>/ prefix", () => {
+ expect(W.assetUrlsFor("/pr-42/en/1.5/")).toEqual({
+ css: "/pr-42/en/1.5/pagefind/pagefind-ui.css",
+ js: "/pr-42/en/1.5/pagefind/pagefind-ui.js",
+ });
+ });
+});
diff --git a/workers/picker-test/picker.test.ts b/workers/picker-test/picker.test.ts
new file mode 100644
index 00000000..33ae2264
--- /dev/null
+++ b/workers/picker-test/picker.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from "vitest";
+// The workers pool has no real filesystem (node:fs readFileSync is an unimplemented
+// stub β€” see @cloudflare/vitest-pool-workers/dist/worker/lib/node/fs.mjs, and confirmed
+// empirically here: "readFileSync() is not yet implemented in Workers"). Same constraint
+// documented in apex/test/manifest.test.ts; import as Vite `?raw` / native JSON assets
+// instead so content is inlined at bundle time β€” no runtime filesystem access needed.
+// eslint-disable-next-line import/no-unresolved
+import src from "../../docs/_static/js/version-picker.js?raw";
+// eslint-disable-next-line import/no-unresolved
+import manifest from "../versions.json";
+
+// Evaluate the plain script and grab its namespace (no DOM access at module scope allowed).
+const ns: Record<string, CallableFunction> = {};
+new Function("window", src)(ns as never);
+const P = (ns as never as { VyOSVersionPicker: Record<string, CallableFunction> }).VyOSVersionPicker;
+
+describe("parseLocation", () => {
+ it("extracts lang/slug/rest from a docs path", () => {
+ expect(P.parseLocation("/en/1.5/cli/index.html"))
+ .toEqual({ lang: "en", slug: "1.5", rest: "cli/index.html" });
+ });
+ it("returns null off the version tree (e.g. previews without prefix knowledge)", () => {
+ expect(P.parseLocation("/kb/x")).toBeNull();
+ });
+});
+
+describe("bannerFor (Β§4)", () => {
+ it("dev β†’ info banner", () => {
+ expect(P.bannerFor("rolling", manifest)).toMatchObject({ kind: "dev" });
+ });
+ it("newest lts β†’ no banner; older lts β†’ newer-lts notice naming 1.5", () => {
+ expect(P.bannerFor("1.5", manifest)).toBeNull();
+ expect(P.bannerFor("1.4", manifest)).toMatchObject({ kind: "newer-lts", newest: "1.5" });
+ });
+ it("eol β†’ warning linking newest LTS", () => {
+ expect(P.bannerFor("1.3", manifest)).toMatchObject({ kind: "eol", newest: "1.5" });
+ });
+});
+
+describe("targetUrlFor", () => {
+ it("same path on target version", () => {
+ expect(P.targetUrlFor({ lang: "en", slug: "1.5", rest: "cli/index.html" }, "1.4"))
+ .toBe("/en/1.4/cli/index.html");
+ });
+});
+
+describe("navUrlFor (query + fragment preserved across version switch)", () => {
+ const loc = { lang: "en", slug: "1.4", rest: "quick-start.html" };
+ it("neither β†’ bare target path", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "")).toBe("/en/1.5/quick-start.html");
+ });
+ it("query-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", ""))
+ .toBe("/en/1.5/quick-start.html?ref=x");
+ });
+ it("hash-only", () => {
+ expect(P.navUrlFor(loc, "1.5", "", "#section-3"))
+ .toBe("/en/1.5/quick-start.html#section-3");
+ });
+ it("both, in query-then-hash order", () => {
+ expect(P.navUrlFor(loc, "1.5", "?ref=x", "#section-3"))
+ .toBe("/en/1.5/quick-start.html?ref=x#section-3");
+ });
+});
diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts
new file mode 100644
index 00000000..5c945175
--- /dev/null
+++ b/workers/preview/src/index.ts
@@ -0,0 +1,61 @@
+export interface Env { PREVIEWS: R2Bucket }
+
+const MIME: Record<string, string> = {
+ html: "text/html; charset=utf-8", css: "text/css", js: "text/javascript",
+ json: "application/json", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg",
+ gif: "image/gif", ico: "image/x-icon", txt: "text/plain; charset=utf-8",
+ xml: "application/xml", pdf: "application/pdf", woff2: "font/woff2", woff: "font/woff",
+};
+
+export function mimeFor(key: string): string {
+ const ext = key.split(".").pop() ?? "";
+ return MIME[ext] ?? "application/octet-stream";
+}
+
+export function keyFor(pathname: string): string {
+ let key = pathname.replace(/^\//, "");
+ if (key.endsWith("/") || key === "" ) key += "index.html";
+ return key;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise<Response> {
+ const key = keyFor(new URL(request.url).pathname);
+ let obj: R2ObjectBody | null = null;
+ let resolvedKey = key;
+ try {
+ obj = await env.PREVIEWS.get(key);
+ if (!obj && !key.split("/").pop()?.includes(".")) {
+ // Extensionless directory URL with no trailing slash (e.g. /en/rolling/cli) β€”
+ // keyFor() only appends index.html for trailing-slash/empty paths, so probe the
+ // directory's index.html before 404ing. Check the LAST path segment only β€” a dot
+ // anywhere earlier (e.g. version segment "1.4" in /pr-42/en/1.4/cli) must not skip
+ // the probe for an otherwise-extensionless final segment.
+ resolvedKey = `${key}/index.html`;
+ obj = await env.PREVIEWS.get(resolvedKey);
+ }
+ } catch {
+ // Transient R2/binding error on either probe β€” fail closed with a controlled 503
+ // instead of letting an unhandled exception surface as a raw worker error.
+ return new Response("preview temporarily unavailable", {
+ status: 503,
+ headers: { "X-Robots-Tag": "noindex", "Cache-Control": "no-store" },
+ });
+ }
+ if (!obj) {
+ return new Response("preview not found", {
+ status: 404,
+ // no-store on the 404 too β€” a cached 404 would persist past the preview upload
+ headers: { "X-Robots-Tag": "noindex", "Cache-Control": "no-store" },
+ });
+ }
+ return new Response(obj.body, {
+ headers: {
+ "content-type": obj.httpMetadata?.contentType ?? mimeFor(resolvedKey),
+ "X-Robots-Tag": "noindex",
+ "Cache-Control": "no-store",
+ "X-Content-Type-Options": "nosniff",
+ },
+ });
+ },
+} satisfies ExportedHandler<Env>;
diff --git a/workers/preview/test/preview.test.ts b/workers/preview/test/preview.test.ts
new file mode 100644
index 00000000..90530690
--- /dev/null
+++ b/workers/preview/test/preview.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from "vitest";
+import worker, { mimeFor, keyFor } from "../src/index";
+import type { Env } from "../src/index";
+
+describe("preview worker helpers (Β§10)", () => {
+ it("derives R2 key from path, defaulting directory to index.html", () => {
+ expect(keyFor("/pr-42/en/rolling/")).toBe("pr-42/en/rolling/index.html");
+ expect(keyFor("/pr-42/en/rolling/cli/index.html")).toBe("pr-42/en/rolling/cli/index.html");
+ });
+ it("extension→MIME fallback map (nosniff-safe)", () => {
+ expect(mimeFor("a.html")).toBe("text/html; charset=utf-8");
+ expect(mimeFor("a.css")).toBe("text/css");
+ expect(mimeFor("a.js")).toBe("text/javascript");
+ expect(mimeFor("a.json")).toBe("application/json");
+ expect(mimeFor("a.svg")).toBe("image/svg+xml");
+ expect(mimeFor("a.woff2")).toBe("font/woff2");
+ expect(mimeFor("a.unknown")).toBe("application/octet-stream");
+ });
+});
+
+// Fake R2Bucket mock following the makeEnv() precedent in apex/test/router.test.ts β€”
+// only the `get` surface the handler consumes; miniflare's real R2 not needed here.
+function makeEnv(objects: Record<string, { body: string; contentType?: string }>): Env {
+ return {
+ PREVIEWS: {
+ get: async (key: string) => {
+ const hit = objects[key];
+ if (!hit) return null;
+ return {
+ body: hit.body,
+ httpMetadata: hit.contentType ? { contentType: hit.contentType } : undefined,
+ };
+ },
+ } as unknown as R2Bucket,
+ };
+}
+
+const get = (path: string, env: Env) =>
+ worker.fetch(new Request(`https://docs-preview.vyos.io${path}`), env);
+
+describe("preview worker fetch entrypoint (Β§10)", () => {
+ it("serves a found object with uploader contentType + noindex/no-store/nosniff headers", async () => {
+ const env = makeEnv({
+ "pr-42/en/rolling/index.html": { body: "<h1>preview</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/rolling/", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>preview</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ expect(r.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ });
+ it("missing object β†’ 404 with noindex AND no-store (404s must never be cached)", async () => {
+ const r = await get("/pr-42/en/rolling/missing.html", makeEnv({}));
+ expect(r.status).toBe(404);
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+ it("object without httpMetadata.contentType falls back to mimeFor(key)", async () => {
+ const env = makeEnv({ "pr-42/en/rolling/style.css": { body: "body{}" } });
+ const r = await get("/pr-42/en/rolling/style.css", env);
+ expect(r.status).toBe(200);
+ expect(r.headers.get("content-type")).toBe("text/css");
+ });
+ it("extensionless directory URL with no trailing slash serves the directory's index.html", async () => {
+ const env = makeEnv({
+ "pr-42/en/rolling/cli/index.html": { body: "<h1>cli</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/rolling/cli", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>cli</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ });
+ it("extensionless directory URL with a dotted version segment earlier in the path still probes index.html (dot-check is last-segment-only)", async () => {
+ const env = makeEnv({
+ "pr-42/en/1.4/cli/index.html": { body: "<h1>cli 1.4</h1>", contentType: "text/html; charset=utf-8" },
+ });
+ const r = await get("/pr-42/en/1.4/cli", env);
+ expect(r.status).toBe(200);
+ expect(await r.text()).toBe("<h1>cli 1.4</h1>");
+ expect(r.headers.get("content-type")).toBe("text/html; charset=utf-8");
+ });
+ it("a transient R2/binding error on either probe degrades to a controlled 503 (never an unhandled exception)", async () => {
+ const throwingEnv: Env = {
+ PREVIEWS: { get: async () => { throw new Error("R2 unavailable"); } } as unknown as R2Bucket,
+ };
+ const r = await get("/pr-42/en/rolling/cli", throwingEnv);
+ expect(r.status).toBe(503);
+ expect(r.headers.get("X-Robots-Tag")).toBe("noindex");
+ expect(r.headers.get("Cache-Control")).toBe("no-store");
+ });
+});
diff --git a/workers/preview/wrangler.jsonc b/workers/preview/wrangler.jsonc
new file mode 100644
index 00000000..fbdb4aba
--- /dev/null
+++ b/workers/preview/wrangler.jsonc
@@ -0,0 +1,11 @@
+{
+ "$schema": "node_modules/wrangler/config-schema.json",
+ "name": "vyos-docs-preview",
+ "main": "src/index.ts",
+ "compatibility_date": "2026-07-01",
+ "workers_dev": false,
+ "preview_urls": false,
+ // Route managed manually (v4.2 amendment β€” CI token has no Workers Routes group):
+ // operator creates docs-preview.vyos.io/* β†’ vyos-docs-preview once, alongside the canary route.
+ "r2_buckets": [{ "binding": "PREVIEWS", "bucket_name": "vyos-docs-previews" }]
+}
diff --git a/workers/versions.json b/workers/versions.json
new file mode 100644
index 00000000..3e349bca
--- /dev/null
+++ b/workers/versions.json
@@ -0,0 +1,19 @@
+{
+ "schema_version": 2,
+ "default_lang": "en",
+ "default_version": "rolling",
+ "languages": [{ "code": "en", "label": "English" }],
+ "versions": [
+ { "slug": "rolling", "label": "Rolling (development)", "status": "dev", "binding": "DOCS_ROLLING",
+ "aliases": ["latest"], "pdf": "/en/rolling/vyos-documentation.pdf" },
+ { "slug": "1.5", "label": "1.5 (Circinus, LTS)", "status": "lts", "binding": "DOCS_V15",
+ "aliases": ["stable", "lts", "circinus"], "pdf": "/en/1.5/vyos-documentation.pdf" },
+ { "slug": "1.4", "label": "1.4 (Sagitta, LTS)", "status": "lts", "binding": "DOCS_V14",
+ "aliases": ["sagitta"], "pdf": "/en/1.4/vyos-documentation.pdf" },
+ { "slug": "1.3", "label": "1.3 (Equuleus, EOL)", "status": "eol", "binding": "DOCS_LEGACY",
+ "aliases": ["equuleus"], "pdf": "/en/1.3/vyos-documentation.pdf",
+ "pdf_r2_key": "legacy/1.3/vyos-documentation.pdf" },
+ { "slug": "1.2", "label": "1.2 (Crux, EOL)", "status": "eol", "binding": "DOCS_LEGACY",
+ "aliases": ["crux"], "pdf": null }
+ ]
+}
diff --git a/workers/vitest.config.ts b/workers/vitest.config.ts
new file mode 100644
index 00000000..70fd2248
--- /dev/null
+++ b/workers/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
+
+export default defineWorkersConfig({
+ test: {
+ poolOptions: {
+ workers: {
+ // Each suite provides its own wrangler config via miniflare option overrides;
+ // apex tests read workers/apex/wrangler.jsonc to assert config congruence.
+ miniflare: {
+ compatibilityDate: "2026-07-01",
+ },
+ },
+ },
+ },
+});