diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 7853c54..f951855 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -30,15 +30,17 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - name: Install dependencies - run: uv sync --all-groups + - name: Install locked dependencies + run: | + uv sync --locked --all-groups + npm ci --ignore-scripts - name: Build generated assets run: make build - name: Verify Cloudflare auth env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: npx --yes wrangler@4.90.0 whoami + run: npm exec -- wrangler whoami - name: Sync Python Workers vendor run: uv run pywrangler sync # Workflow inputs are passed through env vars rather than interpolated diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index cdadbaa..5d5129a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -27,10 +27,10 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - name: Install dependencies - run: uv sync --all-groups - - name: Pin Wrangler for local Worker runtime - run: npm install --global wrangler@4.90.0 + - name: Install locked dependencies + run: | + uv sync --locked --all-groups + npm ci --ignore-scripts - name: Start local Worker for browser checks run: | uv run --group workers pywrangler dev --port 9696 > /tmp/pythonbyexample-ci.log 2>&1 & @@ -50,6 +50,7 @@ jobs: env: CHROME_PATH: /usr/bin/google-chrome run: | + npm audit --audit-level=high make verify scripts/format_examples.py --check make verify-python-version VERSION=3.13 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 09427e9..baea918 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,10 +39,12 @@ make build ## Before opening a pull request -Start the local Worker for browser-backed checks: +Install the committed dependency locks and start the local Worker with the repository-pinned Wrangler: ```bash -uv run --group workers pywrangler dev --port 9696 +uv sync --locked --all-groups +npm ci --ignore-scripts +make dev ``` Then run: diff --git a/Makefile b/Makefile index 1404ce3..d808413 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,14 @@ # All Python tooling runs through uv pinned to 3.13 so the suite works on any # machine regardless of the system python3 (examples use 3.12+ `type` syntax). PY := uv run --python 3.13 +NODE_DEPS_STAMP := node_modules/.package-lock.json -.PHONY: test embed-examples embed-editorial-registry build-search-index build check-generated fingerprint prototypes browser-layout-test search-ranking-test social-cards check-social-cards seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported check-program-covers-cells check-prose-duplication check-inline-links score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes audit-example-graph quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint +.PHONY: node-deps test embed-examples embed-editorial-registry build-search-index build check-generated fingerprint prototypes browser-layout-test search-ranking-test social-cards check-social-cards seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported check-program-covers-cells check-prose-duplication check-inline-links score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes audit-example-graph quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint + +$(NODE_DEPS_STAMP): package.json package-lock.json + npm ci --ignore-scripts + +node-deps: $(NODE_DEPS_STAMP) test: $(PY) python -m unittest discover -s tests -v @@ -104,12 +110,12 @@ lint: verify: build test seo-cache-lint verify-examples quality-checks browser-layout-test search-ranking-test lint check-generated -dev: +dev: node-deps uv run --group workers pywrangler dev --port 9696 smoke-deployment: $(PY) scripts/smoke_deployment.py $(URL) -deploy: check-generated +deploy: node-deps check-generated uv run --group workers pywrangler sync uv run --group workers pywrangler deploy diff --git a/README.md b/README.md index ad74856..cd1cc9d 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,11 @@ This project is developed with red-green-refactor TDD: 2. Implement the smallest change that makes it pass. 3. Refactor while keeping tests green. -Install dependencies with `uv`, then run: +Install the exact committed Python and Node dependency sets, then run: ```bash +uv sync --locked --all-groups +npm ci --ignore-scripts make test ``` @@ -81,10 +83,10 @@ After cloning, install the local git hooks once so merges and rebases regenerate ./scripts/install-git-hooks.sh ``` -Run locally on Workers: +Run locally on Workers with the repository-pinned Wrangler: ```bash -uv run --group workers pywrangler dev --port 9696 +make dev ``` Open: @@ -98,7 +100,7 @@ http://localhost:9696 Run a local Worker before the full browser-backed verification: ```bash -uv run --group workers pywrangler dev --port 9696 +make dev ``` Then run the main checks before deploying or pushing: @@ -197,8 +199,8 @@ make check-generated After adding an example or journey (or changing card title, summary, figure, or card CSS), regenerate social cards. `make check-generated` verifies the -committed card-input provenance and exact JPEG set, without byte-comparing -Chrome's platform-variable raster output: +committed card-input provenance, exact JPEG set, JPEG decodability, and 1200×630 +dimensions without byte-comparing Chrome's platform-variable raster output: ```bash make social-cards @@ -228,4 +230,6 @@ Use the active Cloudflare-supported Python version. - The parent Worker uses a `LOADER` Worker Loader binding. - Dynamic Worker IDs include Python version, example slug, and submitted code hash. - Dynamic Workers run with `globalOutbound: null` and tight CPU/subrequest limits. -- POST example runs are never cached. +- POST example runs carry `Cache-Control: no-store` and are never cached. +- Wrangler is an exact dev dependency in `package-lock.json`; `make dev` and `make deploy` install/use that local version through Pywrangler. +- Production still requires the account-level WAF/rate-limit rule documented in `docs/turnstile-runner-protection-spec.md`; repository settings alone do not prove that external control is active. diff --git a/docs/example-graph-see-also.md b/docs/example-graph-see-also.md index 88a3d55..5f2ec72 100644 --- a/docs/example-graph-see-also.md +++ b/docs/example-graph-see-also.md @@ -38,14 +38,12 @@ see_also = [ ] ``` -The loader exposes `see_also`, the example renderer displays a compact `See also` section, and tests verify that every linked slug exists and does not point to itself. +The loader exposes `see_also`, the example renderer displays a compact `See also` section with registry-backed edge labels such as `contrast` and `next depth`, and tests verify that every linked slug exists and does not point to itself. The same graph supplies bounded recommendations on missing-example pages. ## Graph audit `scripts/audit_example_graph.py` reports orphan pages, high in-degree pages, and reciprocal links. Its `--check` mode runs in `make quality-checks` and fails the build on broken targets, self-links, out-degree above 4, or orphaned examples. -## Future improvements +## Review rule -- Show short edge labels later, e.g. `contrast`, `prerequisite`, `next depth`. -- Use graph data to recommend examples on 404 pages or search results. -- Keep the first version minimal until we know the links improve reading rather than distracting from the code. +Keep labels and recommendations minimal. Each edge still needs editorial review: passing graph checks proves structural integrity, not that a link improves the lesson. diff --git a/docs/learner-analytics.md b/docs/learner-analytics.md index 0e16be7..499d2f0 100644 --- a/docs/learner-analytics.md +++ b/docs/learner-analytics.md @@ -1,23 +1,27 @@ # Learner analytics The Worker emits one structured wide event per request (see -`docs/observability-spec.md`). Those events already carry the fields -that matter for content decisions: the page path, the example slug on -POST runs, whether the submitted code was edited, the Dynamic Worker -outcome, and the execution time. - -`scripts/learner_report.py` turns an export of those events into a -learner-behavior report so content work is steered by external signal -rather than internal quality scores: - -- **Most-read pages** — GET traffic per path. -- **Most-run examples** — POST runs per slug, with the edited share - (are people experimenting or just pressing Run?), the error share - (where edited code fails), and p50/p95 execution times. -- **Journey traffic** — which curated arcs get used. -- **Missing-example requests** — 404s under `/examples/`, i.e. demand - for pages that do not exist yet. These are content candidates. -- **Turnstile outcomes** — how often runs are challenged or blocked. +`docs/observability-spec.md`). Those events carry the page path, the +example slug on POST runs, whether submitted code differed from the +example, the Dynamic Worker outcome, and execution time. + +`scripts/learner_report.py` aggregates those events into an +**operational interaction scorecard**. It does not identify people, +deduplicate visits, measure comprehension, or prove that a content +change caused learning. Every row is a request count within the export's +time window: + +- **Most-requested pages** — successful GET requests per path, including + repeat requests and possible automated traffic. +- **Most-run examples** — accepted POST attempts per slug, with edited, + error, and p50/p95 execution-time distributions. "Edited" means only + that submitted code differed; it is not evidence of successful learning. +- **Journey requests** — GET request volume for curated-arc routes. +- **Missing-example requests** — 404s under `/examples/`. Recurring, + human-validated paths may suggest content candidates, but typos, scanners, + and bots are confounders. +- **Turnstile outcomes** — challenge and rejection counts used to monitor + runner availability and abuse controls, not learner quality. ## Getting events @@ -37,12 +41,18 @@ envelope, so exports from any of the three sources work unmodified. `--json` emits the aggregated report as JSON for further processing; `--limit N` controls rows per section. -## Reading the report - -- A high **edited share** with a low error share means the example - invites successful experimentation — the ideal. -- A high **error share** on edited runs marks pages where learners try - something the example did not prepare them for; consider extending - the walkthrough or notes there. -- **Missing-example requests** that recur are the strongest possible - signal for what to write next. +## Reading the scorecard + +Use the report as a queue for investigation, not as a composite score or +a learning-outcome dashboard. Record the export interval and absolute +sample size, compare equivalent time windows, and inspect paths or code +patterns before changing content. + +- A high **edited share** can justify reviewing an example for useful + extension points. Pair it with error samples and direct learner feedback. +- A high **error share** can indicate unclear teaching, intentionally invalid + experiments, abuse, or runtime trouble. Diagnose those alternatives first. +- Recurring **missing-example requests** are candidates only after filtering + obvious automation and validating the intended topic. +- Do not rank authors or examples by one ratio, and do not claim learning + improvement without a separate outcome measure and an appropriate study. diff --git a/docs/lessons-learned.md b/docs/lessons-learned.md index 083031a..144ce06 100644 --- a/docs/lessons-learned.md +++ b/docs/lessons-learned.md @@ -117,7 +117,7 @@ git diff --check - **Lines must terminate AT elements, not in their gaps or interiors.** A 1.5px gap between a tree edge and a leaf dot reads as "the tree is disconnected" (the `exception-group-peel` bug). A line endpoint 2px inside a circle reads as "the arrow pierces the node" (the `context-bowtie` bug). When connecting to a dot, end the line at the dot's centre and let the dot draw on top — the visual termination is the circumference, with zero gap or overshoot. - **Journey pages render section figures inline.** `SECTION_FIGURES` lives in `src/marginalia.py` (single source of truth, keyed by section title) and `render_for_section(title)` is invoked from `render_journey_page` between each section's meta and its example list. The same paint code that produces the `/prototyping/journey-figures-gestalt` review page renders on production journey pages; drift between the two is structurally impossible. Contract 10 asserts every section in `JOURNEYS` has a figure and every figure name resolves. - **An explicit comparison loop should iterate over the topic's whole spectrum.** When a cell teaches by doing `for label, value in [(...), (...)]: print(...)`, the bracketed list IS the lesson. Two items is a binary contrast; three items reads as a progression. The strings example presented English (pure ASCII, 1 byte/char) against Thai (3 bytes/char) but skipped the Latin-extended middle (French `café`: 4 code points, 5 bytes — `é` is 2 UTF-8 bytes). Adding the middle row turned the cell from "ASCII vs non-Latin" into "1-byte / 2-byte / 3-byte progression." The rule is narrow — most examples spread categories across cells, which is also a valid pattern — but when a comparison loop exists, fill it with the topic's actual spectrum, not just the endpoints. -- **Quality debt must be tracked, not normalized away.** `docs/example-quality-rubric.md` sets a 9.0 target and `scripts/check_quality_scores.py` enforces the score registry: pages below the hard minimum need a concrete improvement backlog entry, stale backlog entries fail once a page clears the gate, and Hello World is the only standing waiver because first examples are traditionally tiny. A score below target is allowed only when the remaining work is named. +- **Quality debt must be tracked, not normalized away.** `docs/example-quality-rubric.md` sets a 9.0 target and `scripts/check_quality_scores.py` enforces the registry's hard minimum; pages below the 9.0 target belong in the scored review queue, and Hello World is the only standing waiver because first examples are traditionally tiny. Registry totals are editorial signals, not measured learning outcomes. - **No-figure decisions need a registry.** Some examples should not have figures, but that cannot be an invisible omission. `scripts/check_no_figure_rationales.py` validates `no_figure_rationales` so future constraint-shaped pages can opt out explicitly instead of shipping weak diagrams. - **Journey sections need outcome contracts.** `scripts/check_journey_outcomes.py` ties each journey section to learner outcomes and support examples so journey pages stay mental maps rather than catalog slices. - **Opaque scores hide the next move.** `scripts/score_example_criteria.py` breaks each page into rubric criteria so quality work can target decomposition, boundaries, source/result pairing, graph support, or practical payoff directly. The bottom-28 pass showed that most misses were boundary/neighbor problems, not syntax problems. `docs/quality-search.md` records the hill-climbing and simulated-annealing loop for escaping locally tidy but globally weak page shapes. @@ -133,12 +133,12 @@ git diff --check - **Extend the existing registry's vocabulary instead of adding a parallel one.** The banner rollout was originally specced as a new `BANNERS` dict keyed by position. But `ATTACHMENTS` is load-bearing for five contract families (score sync, figure usage, caption uniqueness, anchor resolution, gestalt builders), and a second registry would have split that coverage. Extending the anchor vocabulary (`before`, `after-walkthrough`, `after-cell-N` alongside legacy `cell-N`) delivered the same grammar with every existing contract intact. Same lesson as "one paint registry, not two," applied to data shape migrations. - **Dark mode for locked-palette SVGs: change the mat, not the art.** The marginalia grammar hardcodes the light palette in every figure, and recolouring 109 figures would have meant touching the locked grammar and re-auditing geometry. Instead, dark mode renders each figure on a light "paper" chip (`--figure-paper` background, small padding, rounded corners). The figures stay byte-identical, read as intentional artifacts, and the grammar's palette constraint survives. -- **Dual-theme code highlighting needs both pipelines.** Shiki supports `themes: { light, dark }` and emits `--shiki-dark` CSS variables that a `prefers-color-scheme` block activates — no second render pass. CodeMirror has no equivalent, so `editor.js` picks `defaultHighlightStyle` vs `oneDarkHighlightStyle` from `matchMedia` at init. Two highlighters, two theming mechanisms; forgetting either leaves unreadable code in one scheme. -- **Rasterized artifacts do not belong in byte-parity gates.** Social cards are committed PNGs-turned-JPEGs rendered by headless Chrome, and rasterized bytes vary across Chrome versions and platforms. Putting `public/og/` under `make check-generated` would flap on every environment difference. The SEO linter instead checks *existence* — every `og:image` URL must resolve to a committed file — which catches the real failure (a new example without a card) without the false ones. Corollary: JPEG q90 beats PNG ~4x on file size for cards with gradient backgrounds, with no visible text degradation at 1200x630. -- **Social cards should reuse the curated figure set.** Each example's card composes its marginalia figure beside the title and summary, so a shared link carries the same diagram the page teaches with. `render_first_figure(slug)` is the only new marginalia surface the card builder needed; one Chrome session rasterizes all 110 cards in seconds via CDP navigation + `Page.captureScreenshot` clips. +- **Theme changes need live browser state, not just first-paint CSS.** Read-only Shiki blocks are rendered with the current light/dark theme and rerender when the OS preference changes. CodeMirror rebuilds its theme compartment from the same `matchMedia` change signal. Both also retain readable plain-text fallbacks when the optional CDN graph is unavailable. +- **Rasterized artifacts need structural gates, not byte-parity gates.** Social-card bytes vary across Chrome versions and platforms, so CI validates deterministic input provenance, the exact expected JPEG filename set, JPEG decodability, and 1200×630 dimensions rather than comparing raster bytes. The SEO linter separately verifies that every `og:image` URL resolves to a committed card. +- **Social cards should reuse the curated figure set.** Each example's card composes its marginalia figure beside the title and summary, so a shared link carries the same diagram the page teaches with. `render_first_figure(slug)` is the only new marginalia surface the card builder needed; one Chrome session rasterizes the current 119-card set via CDP navigation and `Page.captureScreenshot` clips. - **Copy that describes data state goes stale silently.** Journey meta descriptions still claimed "explicit placeholders for missing examples" long after the last gap placeholder was filled, and the gap-rendering UI suggested holes that no longer existed. Prose that asserts a property of the data ("all examples run," "placeholders mark gaps") should either be derived from the data or covered by a check; otherwise fixing the data falsifies the copy. - **Make targets that import the example loader must go through uv.** The loader executes example code that uses 3.12+ syntax (`type UserId = int`), so any script importing `src.app` breaks under an older system `python3` even though its shebang says `python3`. CI masks this by installing 3.13 as the default interpreter. Build steps that import the catalog (`build_search_index`, `build_social_cards`) run as `uv run --python 3.13 scripts/...` in the Makefile so local machines with an older `python3` behave like CI. - **Index notes text, not just titles, and normalize at build time.** The search index concatenates the slug words and every note line, lowercased at build time, so concept queries ("walrus", "GIL"-style vocabulary that titles avoid) hit the right example and the client never re-normalizes entry content. Exporting the ranking function from `search.js` lets a plain Node script (`make search-ranking-test`) assert ranking behaviour against the real generated index without a JS test framework. - **The system stack is the typography; never lead a stack with a face nobody has.** From the first commit the font stacks led with FT Kunst Grotesk and Apercu Mono Pro, but no `@font-face` ever shipped and the faces were not installed anywhere — not on visitors' machines, not on the maintainer's Mac, not on the build machine that rasterizes the social cards. They rendered for no one, ever, and were removed on 2026-07-10. The stacks now declare what actually renders: `system-ui` for prose, `ui-monospace` first for code. Body type is `100%/1.6` so browser text-size settings are respected, and `/about` documents the stack honestly. - **UI icons are a deliberate, minimal vocabulary.** The site's chrome is text-first; the only UI icons are the Lucide copy/check/x glyphs on the code-cell copy button, drawn as `currentColor` CSS masks so every token state (muted, hover, accent-on-copied, dark mode) flows through with no icon-specific color rules. Toolbar actions (Run, Reset, Copy link) stay text because no glyph says "copy a URL containing your edit" unambiguously. Any further icon must update this entry and justify itself the same way. -- **The wide events already knew what learners do; nobody was asking.** `example.slug`, `code_edited`, `execution_ms`, turnstile outcome, and 404 paths were all in the observability payload before any analytics existed. `scripts/learner_report.py` is a pure consumer: it auto-detects the raw payload, `wrangler tail` envelope, and Workers Logs envelope per line, so exports from any source work unmodified. The most valuable section costs nothing to collect: recurring 404s under `/examples/` are direct demand for pages that do not exist. +- **Wide events support an operational interaction scorecard, not learning claims.** `example.slug`, `code_edited`, `execution_ms`, Turnstile outcome, and 404 paths were already present. `scripts/learner_report.py` aggregates raw, `wrangler tail`, and Workers Logs envelopes, but its counts include repeats and possible bots. Recurring 404s and edited-run ratios create investigation queues only after sample-size and traffic-quality review; they do not measure comprehension. diff --git a/docs/pr-evidence/README.md b/docs/pr-evidence/README.md index c1b99cd..38aa6db 100644 --- a/docs/pr-evidence/README.md +++ b/docs/pr-evidence/README.md @@ -1,4 +1,25 @@ -# PR #6 visual evidence +# PR visual evidence + +## Audit remediation (2026-07-10) + +These captures isolate the dark-mode Run-button contrast correction on the same `/examples/values` runner at a 1200×900 desktop viewport. + +| Evidence | Review point | Commit | SHA-256 | +| --- | --- | --- | --- | +| [Before](audit-remediation-before-runner-dark.png) | The prior light text on the orange Run button measured 2.19:1 contrast. | `46000c7` (`origin/main`) | `4f15bc0d6f2c965197265bff2f6b300aee5a7a32e4ef152001aeb6c8d290e860` | +| [After](audit-remediation-after-runner-dark.png) | Dark action text raises measured contrast to 7.87:1 while preserving the runner layout. | `audit-remediation-2026-07-10` | `53882092e58e02bbe323c5ed70e0d1c491f1e8e9f6887416b7ec83222ad305cc` | + +Reproduce each capture by serving the corresponding revision, then run: + +```bash +TARGET_URL=http://127.0.0.1:/examples/values \ +CAPTURE_SELECTOR=.runner-grid WIDTH=1200 HEIGHT=900 COLOR_SCHEME=dark \ +OUTPUT=.png node scripts/capture_browser_screenshot.mjs +``` + +`make browser-layout-test` independently calculates the rendered button contrast and fails below 4.5:1. + +## PR #6 evidence These artifacts provide reviewable evidence for the runner, journey-page, and social-card changes. @@ -9,7 +30,7 @@ These artifacts provide reviewable evidence for the runner, journey-page, and so | [Runtime journey page](pr-6-journey-runtime.png) | `/journeys/runtime` renders its overview, first learning outcome, figure, and linked examples at a 1200×1260 desktop viewport. | `a85bbf7` | `eb1db35d5ff7fc7974ad3a3891b694aaa5f8f277ba291b94a24c62767340e04d` | | [`/journeys` social card](../../public/og/journeys.jpg) | The exact 1200×630 JPEG referenced by the `/journeys` `og:image` tag. | `a85bbf7` | `3042997bb2b3acd9e2ab74106f5b4d3d4c58dafcfe23f1ddc548ed39e4548a3c` | -`public/og/journeys.jpg` is a tracked PR artifact and its deterministic HTML input is recorded in `public/og/manifest.json`. `make check-social-cards` verifies both its provenance and the exact 118-card JPEG set. +`public/og/journeys.jpg` is a tracked PR artifact and its deterministic HTML input is recorded in `public/og/manifest.json`. `make check-social-cards` verifies its provenance, the exact 119-card JPEG set, and each card's JPEG decodability and 1200×630 dimensions. Regenerate each page with its commit checked out, serve the rendered `/examples/values` HTML plus `public/` on a local HTTP server, then run: diff --git a/docs/pr-evidence/audit-remediation-after-runner-dark.png b/docs/pr-evidence/audit-remediation-after-runner-dark.png new file mode 100644 index 0000000..366e820 Binary files /dev/null and b/docs/pr-evidence/audit-remediation-after-runner-dark.png differ diff --git a/docs/pr-evidence/audit-remediation-before-runner-dark.png b/docs/pr-evidence/audit-remediation-before-runner-dark.png new file mode 100644 index 0000000..8c9fe4c Binary files /dev/null and b/docs/pr-evidence/audit-remediation-before-runner-dark.png differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9d69098 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1551 @@ +{ + "name": "pythonbyexample-tooling", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pythonbyexample-tooling", + "devDependencies": { + "wrangler": "4.110.0" + } + }, + "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/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/@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/@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/@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/@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/@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/@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.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/@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/@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/@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/@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, + "libc": [ + "glibc" + ], + "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.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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "glibc" + ], + "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.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, + "libc": [ + "glibc" + ], + "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.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, + "libc": [ + "glibc" + ], + "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.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, + "libc": [ + "musl" + ], + "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.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, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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, + "libc": [ + "glibc" + ], + "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/@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, + "libc": [ + "glibc" + ], + "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/@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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "glibc" + ], + "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.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, + "libc": [ + "glibc" + ], + "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/@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, + "libc": [ + "glibc" + ], + "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/@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, + "libc": [ + "musl" + ], + "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/@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, + "libc": [ + "musl" + ], + "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/@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/@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.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/@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/@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/@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/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/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/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/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/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/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/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/miniflare": { + "version": "4.20260708.1", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260708.1.tgz", + "integrity": "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA==", + "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/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/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.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/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/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/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/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": { + "version": "4.110.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.110.0.tgz", + "integrity": "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg==", + "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.1", + "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/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/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" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb81650 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "pythonbyexample-tooling", + "private": true, + "type": "module", + "packageManager": "npm@11.4.2", + "scripts": { + "wrangler": "wrangler" + }, + "devDependencies": { + "wrangler": "4.110.0" + } +} diff --git a/public/editor.a550bf2266bd.js b/public/editor.3827f6497b54.js similarity index 79% rename from public/editor.a550bf2266bd.js rename to public/editor.3827f6497b54.js index e774839..9296650 100644 --- a/public/editor.a550bf2266bd.js +++ b/public/editor.3827f6497b54.js @@ -1,11 +1,12 @@ -import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; +import { Compartment, EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; import { oneDarkHighlightStyle } from 'https://esm.sh/@codemirror/theme-one-dark@6.1.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; -const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; -const highlightStyle = prefersDark ? oneDarkHighlightStyle : defaultHighlightStyle; +const themePreference = window.matchMedia('(prefers-color-scheme: dark)'); +const highlightStyle = () => themePreference.matches ? oneDarkHighlightStyle : defaultHighlightStyle; +const highlightCompartment = new Compartment(); const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -18,7 +19,7 @@ if (textarea && form) { doc: textarea.value, extensions: [ python(), - syntaxHighlighting(highlightStyle), + highlightCompartment.of(syntaxHighlighting(highlightStyle())), lineNumbers(), EditorView.lineWrapping, EditorView.contentAttributes.of({ @@ -33,6 +34,9 @@ if (textarea && form) { textarea.insertAdjacentElement('afterend', view.dom); textarea.hidden = true; + themePreference.addEventListener?.('change', () => { + view.dispatch({ effects: highlightCompartment.reconfigure(syntaxHighlighting(highlightStyle())) }); + }); function resize() { view.dom.style.minHeight = ''; diff --git a/public/editor.js b/public/editor.js index e774839..9296650 100644 --- a/public/editor.js +++ b/public/editor.js @@ -1,11 +1,12 @@ -import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; +import { Compartment, EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; import { oneDarkHighlightStyle } from 'https://esm.sh/@codemirror/theme-one-dark@6.1.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; -const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; -const highlightStyle = prefersDark ? oneDarkHighlightStyle : defaultHighlightStyle; +const themePreference = window.matchMedia('(prefers-color-scheme: dark)'); +const highlightStyle = () => themePreference.matches ? oneDarkHighlightStyle : defaultHighlightStyle; +const highlightCompartment = new Compartment(); const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -18,7 +19,7 @@ if (textarea && form) { doc: textarea.value, extensions: [ python(), - syntaxHighlighting(highlightStyle), + highlightCompartment.of(syntaxHighlighting(highlightStyle())), lineNumbers(), EditorView.lineWrapping, EditorView.contentAttributes.of({ @@ -33,6 +34,9 @@ if (textarea && form) { textarea.insertAdjacentElement('afterend', view.dom); textarea.hidden = true; + themePreference.addEventListener?.('change', () => { + view.dispatch({ effects: highlightCompartment.reconfigure(syntaxHighlighting(highlightStyle())) }); + }); function resize() { view.dom.style.minHeight = ''; diff --git a/public/runner.d3515b57bc3e.js b/public/runner.96b105eef218.js similarity index 69% rename from public/runner.d3515b57bc3e.js rename to public/runner.96b105eef218.js index eed3988..52e2a6f 100644 --- a/public/runner.d3515b57bc3e.js +++ b/public/runner.96b105eef218.js @@ -1,3 +1,6 @@ +const MAX_SHARE_FRAGMENT_CHARS = 24000; +const TURNSTILE_ACTION = 'run-example'; + function initializeRunner() { const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -17,22 +20,30 @@ function initializeRunner() { window.pythonByExampleEditor?.setValue(value); }; + let cancelActiveRun = () => {}; const resetButton = form.querySelector('[data-reset]'); if (resetButton) { resetButton.addEventListener('click', () => { + cancelActiveRun(); setCode(originalCode); window.pythonByExampleEditor?.focus(); }); } let sharedCodeLoaded = false; + let sharedCodeRejected = false; const hash = new URL(window.location.href).hash; if (hash.startsWith('#code=')) { - try { - setCode(decodeURIComponent(escape(atob(hash.slice(6))))); - sharedCodeLoaded = textarea.value !== originalCode; - } catch (_) { - // Ignore malformed share fragments and leave the authored example intact. + const encodedCode = hash.slice(6); + if (encodedCode.length > MAX_SHARE_FRAGMENT_CHARS) { + sharedCodeRejected = true; + } else { + try { + setCode(decodeURIComponent(escape(atob(encodedCode)))); + sharedCodeLoaded = textarea.value !== originalCode; + } catch (_) { + sharedCodeRejected = true; + } } } @@ -85,9 +96,11 @@ function initializeRunner() { const outputPanel = document.querySelector('.output-panel'); if (!outputPanel) return; - if (sharedCodeLoaded) { + if (sharedCodeLoaded || sharedCodeRejected) { outputPanel.querySelector('h3').textContent = 'Output'; - outputPanel.querySelector('code').textContent = 'This link included edited code. Press Run to execute it.'; + outputPanel.querySelector('code').textContent = sharedCodeLoaded + ? 'This link included edited code. Press Run to execute it.' + : 'The shared-code fragment was invalid or too large, so the authored example was kept.'; } const challengeBox = document.querySelector('[data-turnstile-sitekey]'); let turnstileWidgetId = null; @@ -101,8 +114,20 @@ function initializeRunner() { script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; script.async = true; script.defer = true; - script.onload = resolve; - script.onerror = () => reject(new Error('Turnstile failed to load')); + script.onload = () => { + if (window.turnstile) { + resolve(); + return; + } + loadingTurnstile = null; + script.remove(); + reject(new Error('Turnstile loaded without its browser API')); + }; + script.onerror = () => { + loadingTurnstile = null; + script.remove(); + reject(new Error('Turnstile failed to load; press Run to retry')); + }; document.head.appendChild(script); }); return loadingTurnstile; @@ -126,27 +151,30 @@ function initializeRunner() { challengeBox.innerHTML = ''; } - async function requestTurnstileToken() { + async function requestTurnstileToken(signal) { if (!challengeBox) throw new Error('Turnstile challenge is not configured'); await loadTurnstile(); + if (signal.aborted) throw new DOMException('Run cancelled', 'AbortError'); challengeBox.hidden = false; return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + removeTurnstile(); + callback(value); + }; + const onAbort = () => finish(reject, new DOMException('Run cancelled', 'AbortError')); + signal.addEventListener('abort', onAbort, { once: true }); challengeBox.innerHTML = ''; turnstileWidgetId = turnstile.render(challengeBox, { sitekey: challengeBox.dataset.turnstileSitekey, + action: TURNSTILE_ACTION, execution: 'execute', - callback: (token) => { - removeTurnstile(); - resolve(token); - }, - 'error-callback': () => { - removeTurnstile(); - reject(new Error('Turnstile challenge failed')); - }, - 'expired-callback': () => { - removeTurnstile(); - reject(new Error('Turnstile challenge expired')); - }, + callback: token => finish(resolve, token), + 'error-callback': () => finish(reject, new Error('Turnstile challenge failed')), + 'expired-callback': () => finish(reject, new Error('Turnstile challenge expired')), }); turnstile.execute(turnstileWidgetId); }); @@ -162,37 +190,68 @@ function initializeRunner() { || `HTTP ${response.status}`; } - async function submitRun(turnstileToken = '') { - outputPanel.removeAttribute('data-output-placeholder'); + let latestRunId = 0; + let activeController = null; + const runButton = form.querySelector('button[type="submit"]'); + + async function submitRun(runId, signal, turnstileToken = '') { window.pythonByExampleEditor?.syncTextarea(); - outputPanel.querySelector('code').textContent = 'Running in a Dynamic Python Worker…'; const formData = new FormData(form); if (turnstileToken) formData.set('cf-turnstile-response', turnstileToken); const response = await fetch(form.action, { method: 'POST', body: new URLSearchParams(formData), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + signal, }); const html = await response.text(); + if (runId !== latestRunId) return; if (!response.ok) throw new Error(responseErrorMessage(response, html)); const document = new DOMParser().parseFromString(html, 'text/html'); const challengeRequired = document.querySelector('[data-turnstile-required]'); if (challengeRequired) { outputPanel.querySelector('code').textContent = challengeRequired.textContent || 'Verification required before running edited code…'; - return submitRun(await requestTurnstileToken()); + const token = await requestTurnstileToken(signal); + if (runId !== latestRunId) return; + return submitRun(runId, signal, token); } const nextOutput = document.querySelector('.output-panel'); if (nextOutput) outputPanel.innerHTML = nextOutput.innerHTML; } + cancelActiveRun = () => { + latestRunId += 1; + activeController?.abort(); + activeController = null; + removeTurnstile(); + form.removeAttribute('aria-busy'); + if (runButton) runButton.disabled = false; + }; + form.addEventListener('submit', async (event) => { event.preventDefault(); + activeController?.abort(); + const runId = ++latestRunId; + const controller = new AbortController(); + activeController = controller; + outputPanel.removeAttribute('data-output-placeholder'); + outputPanel.querySelector('code').textContent = 'Running in a Dynamic Python Worker…'; + form.setAttribute('aria-busy', 'true'); + if (runButton) runButton.disabled = true; try { - await submitRun(); + await submitRun(runId, controller.signal); } catch (error) { - removeTurnstile(); - outputPanel.querySelector('code').textContent = `Run failed: ${error.message}`; + if (runId === latestRunId && error.name !== 'AbortError') { + removeTurnstile(); + outputPanel.querySelector('code').textContent = `Run failed: ${error.message}`; + } + } finally { + if (runId === latestRunId) { + activeController = null; + form.removeAttribute('aria-busy'); + if (runButton) runButton.disabled = false; + } } }); } diff --git a/public/runner.js b/public/runner.js index eed3988..52e2a6f 100644 --- a/public/runner.js +++ b/public/runner.js @@ -1,3 +1,6 @@ +const MAX_SHARE_FRAGMENT_CHARS = 24000; +const TURNSTILE_ACTION = 'run-example'; + function initializeRunner() { const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -17,22 +20,30 @@ function initializeRunner() { window.pythonByExampleEditor?.setValue(value); }; + let cancelActiveRun = () => {}; const resetButton = form.querySelector('[data-reset]'); if (resetButton) { resetButton.addEventListener('click', () => { + cancelActiveRun(); setCode(originalCode); window.pythonByExampleEditor?.focus(); }); } let sharedCodeLoaded = false; + let sharedCodeRejected = false; const hash = new URL(window.location.href).hash; if (hash.startsWith('#code=')) { - try { - setCode(decodeURIComponent(escape(atob(hash.slice(6))))); - sharedCodeLoaded = textarea.value !== originalCode; - } catch (_) { - // Ignore malformed share fragments and leave the authored example intact. + const encodedCode = hash.slice(6); + if (encodedCode.length > MAX_SHARE_FRAGMENT_CHARS) { + sharedCodeRejected = true; + } else { + try { + setCode(decodeURIComponent(escape(atob(encodedCode)))); + sharedCodeLoaded = textarea.value !== originalCode; + } catch (_) { + sharedCodeRejected = true; + } } } @@ -85,9 +96,11 @@ function initializeRunner() { const outputPanel = document.querySelector('.output-panel'); if (!outputPanel) return; - if (sharedCodeLoaded) { + if (sharedCodeLoaded || sharedCodeRejected) { outputPanel.querySelector('h3').textContent = 'Output'; - outputPanel.querySelector('code').textContent = 'This link included edited code. Press Run to execute it.'; + outputPanel.querySelector('code').textContent = sharedCodeLoaded + ? 'This link included edited code. Press Run to execute it.' + : 'The shared-code fragment was invalid or too large, so the authored example was kept.'; } const challengeBox = document.querySelector('[data-turnstile-sitekey]'); let turnstileWidgetId = null; @@ -101,8 +114,20 @@ function initializeRunner() { script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; script.async = true; script.defer = true; - script.onload = resolve; - script.onerror = () => reject(new Error('Turnstile failed to load')); + script.onload = () => { + if (window.turnstile) { + resolve(); + return; + } + loadingTurnstile = null; + script.remove(); + reject(new Error('Turnstile loaded without its browser API')); + }; + script.onerror = () => { + loadingTurnstile = null; + script.remove(); + reject(new Error('Turnstile failed to load; press Run to retry')); + }; document.head.appendChild(script); }); return loadingTurnstile; @@ -126,27 +151,30 @@ function initializeRunner() { challengeBox.innerHTML = ''; } - async function requestTurnstileToken() { + async function requestTurnstileToken(signal) { if (!challengeBox) throw new Error('Turnstile challenge is not configured'); await loadTurnstile(); + if (signal.aborted) throw new DOMException('Run cancelled', 'AbortError'); challengeBox.hidden = false; return new Promise((resolve, reject) => { + let settled = false; + const finish = (callback, value) => { + if (settled) return; + settled = true; + signal.removeEventListener('abort', onAbort); + removeTurnstile(); + callback(value); + }; + const onAbort = () => finish(reject, new DOMException('Run cancelled', 'AbortError')); + signal.addEventListener('abort', onAbort, { once: true }); challengeBox.innerHTML = ''; turnstileWidgetId = turnstile.render(challengeBox, { sitekey: challengeBox.dataset.turnstileSitekey, + action: TURNSTILE_ACTION, execution: 'execute', - callback: (token) => { - removeTurnstile(); - resolve(token); - }, - 'error-callback': () => { - removeTurnstile(); - reject(new Error('Turnstile challenge failed')); - }, - 'expired-callback': () => { - removeTurnstile(); - reject(new Error('Turnstile challenge expired')); - }, + callback: token => finish(resolve, token), + 'error-callback': () => finish(reject, new Error('Turnstile challenge failed')), + 'expired-callback': () => finish(reject, new Error('Turnstile challenge expired')), }); turnstile.execute(turnstileWidgetId); }); @@ -162,37 +190,68 @@ function initializeRunner() { || `HTTP ${response.status}`; } - async function submitRun(turnstileToken = '') { - outputPanel.removeAttribute('data-output-placeholder'); + let latestRunId = 0; + let activeController = null; + const runButton = form.querySelector('button[type="submit"]'); + + async function submitRun(runId, signal, turnstileToken = '') { window.pythonByExampleEditor?.syncTextarea(); - outputPanel.querySelector('code').textContent = 'Running in a Dynamic Python Worker…'; const formData = new FormData(form); if (turnstileToken) formData.set('cf-turnstile-response', turnstileToken); const response = await fetch(form.action, { method: 'POST', body: new URLSearchParams(formData), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + signal, }); const html = await response.text(); + if (runId !== latestRunId) return; if (!response.ok) throw new Error(responseErrorMessage(response, html)); const document = new DOMParser().parseFromString(html, 'text/html'); const challengeRequired = document.querySelector('[data-turnstile-required]'); if (challengeRequired) { outputPanel.querySelector('code').textContent = challengeRequired.textContent || 'Verification required before running edited code…'; - return submitRun(await requestTurnstileToken()); + const token = await requestTurnstileToken(signal); + if (runId !== latestRunId) return; + return submitRun(runId, signal, token); } const nextOutput = document.querySelector('.output-panel'); if (nextOutput) outputPanel.innerHTML = nextOutput.innerHTML; } + cancelActiveRun = () => { + latestRunId += 1; + activeController?.abort(); + activeController = null; + removeTurnstile(); + form.removeAttribute('aria-busy'); + if (runButton) runButton.disabled = false; + }; + form.addEventListener('submit', async (event) => { event.preventDefault(); + activeController?.abort(); + const runId = ++latestRunId; + const controller = new AbortController(); + activeController = controller; + outputPanel.removeAttribute('data-output-placeholder'); + outputPanel.querySelector('code').textContent = 'Running in a Dynamic Python Worker…'; + form.setAttribute('aria-busy', 'true'); + if (runButton) runButton.disabled = true; try { - await submitRun(); + await submitRun(runId, controller.signal); } catch (error) { - removeTurnstile(); - outputPanel.querySelector('code').textContent = `Run failed: ${error.message}`; + if (runId === latestRunId && error.name !== 'AbortError') { + removeTurnstile(); + outputPanel.querySelector('code').textContent = `Run failed: ${error.message}`; + } + } finally { + if (runId === latestRunId) { + activeController = null; + form.removeAttribute('aria-busy'); + if (runButton) runButton.disabled = false; + } } }); } diff --git a/public/search.158bd7afa114.js b/public/search.e5a5822d9917.js similarity index 84% rename from public/search.158bd7afa114.js rename to public/search.e5a5822d9917.js index 2496724..9f18203 100644 --- a/public/search.158bd7afa114.js +++ b/public/search.e5a5822d9917.js @@ -34,16 +34,28 @@ function wireSearch() { const container = document.querySelector('.site-search'); const input = document.getElementById('site-search-input'); const results = document.getElementById('site-search-results'); - if (!container || !input || !results) return; + const status = document.getElementById('site-search-status'); + if (!container || !input || !results || !status) return; let entries = null; let loading = null; function loadIndex() { if (!loading) { loading = fetch(container.dataset.searchIndex) - .then((response) => response.json()) - .then((data) => { entries = data; }) - .catch(() => { loading = null; }); + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((data) => { + if (!Array.isArray(data)) throw new Error('Search index is not an array'); + entries = data; + status.textContent = ''; + }) + .catch(() => { + loading = null; + entries = null; + status.textContent = 'Search is temporarily unavailable. Please browse the example sections below.'; + }); } return loading; } @@ -83,11 +95,14 @@ function wireSearch() { results.replaceChildren(empty); results.hidden = false; input.setAttribute('aria-expanded', 'true'); + status.textContent = 'No examples match your search.'; } else { hideResults(); + status.textContent = ''; } return; } + status.textContent = `${matches.length} matching example${matches.length === 1 ? '' : 's'}.`; results.replaceChildren(...matches.map(resultNode)); results.hidden = false; input.setAttribute('aria-expanded', 'true'); @@ -100,6 +115,7 @@ function wireSearch() { if (event.key === 'Escape') { input.value = ''; hideResults(); + status.textContent = ''; } else if (event.key === 'ArrowDown' && links.length) { event.preventDefault(); links[0].focus(); diff --git a/public/search.js b/public/search.js index 2496724..9f18203 100644 --- a/public/search.js +++ b/public/search.js @@ -34,16 +34,28 @@ function wireSearch() { const container = document.querySelector('.site-search'); const input = document.getElementById('site-search-input'); const results = document.getElementById('site-search-results'); - if (!container || !input || !results) return; + const status = document.getElementById('site-search-status'); + if (!container || !input || !results || !status) return; let entries = null; let loading = null; function loadIndex() { if (!loading) { loading = fetch(container.dataset.searchIndex) - .then((response) => response.json()) - .then((data) => { entries = data; }) - .catch(() => { loading = null; }); + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }) + .then((data) => { + if (!Array.isArray(data)) throw new Error('Search index is not an array'); + entries = data; + status.textContent = ''; + }) + .catch(() => { + loading = null; + entries = null; + status.textContent = 'Search is temporarily unavailable. Please browse the example sections below.'; + }); } return loading; } @@ -83,11 +95,14 @@ function wireSearch() { results.replaceChildren(empty); results.hidden = false; input.setAttribute('aria-expanded', 'true'); + status.textContent = 'No examples match your search.'; } else { hideResults(); + status.textContent = ''; } return; } + status.textContent = `${matches.length} matching example${matches.length === 1 ? '' : 's'}.`; results.replaceChildren(...matches.map(resultNode)); results.hidden = false; input.setAttribute('aria-expanded', 'true'); @@ -100,6 +115,7 @@ function wireSearch() { if (event.key === 'Escape') { input.value = ''; hideResults(); + status.textContent = ''; } else if (event.key === 'ArrowDown' && links.length) { event.preventDefault(); links[0].focus(); diff --git a/public/site.4ad21a6ce8a3.css b/public/site.07c59ae07f87.css similarity index 95% rename from public/site.4ad21a6ce8a3.css rename to public/site.07c59ae07f87.css index b700b56..088a60c 100644 --- a/public/site.4ad21a6ce8a3.css +++ b/public/site.07c59ae07f87.css @@ -1,6 +1,8 @@ :root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --terminal-bg: #0b1020; --terminal-ink: #f9fafb; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + :where(a, button, input, textarea, [tabindex]):focus-visible { outline: 3px solid var(--accent-text); outline-offset: 3px; } + :where(button, input, textarea):disabled { cursor: not-allowed; opacity: .68; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 100%/1.6 system-ui, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: var(--header-veil); } .skip-link { position: absolute; left: -9999px; } @@ -15,11 +17,14 @@ p, li { text-wrap: pretty; } pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: var(--terminal-bg); color: var(--terminal-ink); box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); } textarea { box-sizing: border-box; width: 100%; height: auto; min-height: 18rem; padding: 0; overflow: hidden; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; tab-size: 4; transition-property: box-shadow; transition-duration: 150ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } - textarea:focus { box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } + textarea:focus-visible { outline: 3px solid var(--accent-text); outline-offset: 3px; box-shadow: 0 0 0 6px var(--accent-soft); } .textarea-fallback[hidden] { display: none; } .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; } - .cm-editor.cm-focused { outline: 0; box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } - .cm-scroller { font-family: inherit; line-height: 1.5; } + .cm-editor.cm-focused { outline: 3px solid var(--accent-text); outline-offset: 3px; box-shadow: 0 0 0 6px var(--accent-soft); } + .cm-scroller { font-family: inherit; line-height: 1.5; scrollbar-color: var(--accent-text) var(--surface-3); scrollbar-width: thin; } + .cm-scroller::-webkit-scrollbar { width: 10px; height: 10px; } + .cm-scroller::-webkit-scrollbar-track { background: var(--surface-3); } + .cm-scroller::-webkit-scrollbar-thumb { border: 2px solid var(--surface-3); border-radius: 999px; background: var(--accent-text); } .cm-content { padding: 0; } .cm-line { padding: 0; } .cm-gutters { background: transparent; border-right: 1px solid var(--hairline-soft); color: var(--muted); font-variant-numeric: tabular-nums; } @@ -69,12 +74,13 @@ } .site-search { position: relative; width: 100%; max-width: none; margin: 0 0 var(--space-5); } .site-search input { width: 100%; min-height: 44px; padding: .6rem 1rem; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); color: var(--text); font: inherit; transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } - .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + .site-search input:focus-visible { outline: 3px solid var(--accent-text); outline-offset: 2px; border-color: var(--accent-text); box-shadow: 0 0 0 6px var(--accent-soft); } .site-search input::placeholder { color: var(--muted); } + .search-status { min-height: 1.4em; margin: .35rem 0 0; color: var(--muted); font-size: .88rem; } .search-results { position: absolute; inset-inline: 0; top: 100%; z-index: 3; margin: .35rem 0 0; padding: .35rem; list-style: none; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); box-shadow: 0 12px 42px rgba(0, 0, 0, .14); } .search-results li { margin: 0; } .search-results a { display: block; padding: .5rem .65rem; border-radius: .5rem; color: var(--text); text-decoration: none; } - .search-results a:hover, .search-results a:focus { background: var(--accent-soft); outline: 0; } + .search-results a:hover, .search-results a:focus-visible { background: var(--accent-soft); outline: 3px solid var(--accent-text); outline-offset: -1px; } .search-results .search-empty { padding: .5rem .65rem; color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } .home-section { margin-top: var(--space-6); } @@ -242,8 +248,7 @@ --header-veil-solid: rgba(27, 18, 11, 0.95); } .cell-banner figure svg, .journey-section-figure svg { padding: var(--space-2); border-radius: .5rem; background: var(--figure-paper); } - .button { color: #FFF7EF; } - .shiki, .shiki span { color: var(--shiki-dark) !important; } + .button { color: #2A0B00; } } /* Non-motion accessibility signals. Reduced motion is handled by the transition kill switch and no-preference gates above; these diff --git a/public/site.css b/public/site.css index b700b56..088a60c 100644 --- a/public/site.css +++ b/public/site.css @@ -1,6 +1,8 @@ :root { color-scheme: light; --accent: #FF4801; --accent-text: #C83800; --accent-action: #C83800; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --terminal-bg: #0b1020; --terminal-ink: #f9fafb; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } + :where(a, button, input, textarea, [tabindex]):focus-visible { outline: 3px solid var(--accent-text); outline-offset: 3px; } + :where(button, input, textarea):disabled { cursor: not-allowed; opacity: .68; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 100%/1.6 system-ui, -apple-system, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: var(--header-veil); } .skip-link { position: absolute; left: -9999px; } @@ -15,11 +17,14 @@ p, li { text-wrap: pretty; } pre { overflow: auto; padding: 1rem; border-radius: 1rem; background: var(--terminal-bg); color: var(--terminal-ink); box-shadow: 0 1px 1px rgba(0,0,0,.12), 0 12px 42px rgba(0,0,0,.18); } textarea { box-sizing: border-box; width: 100%; height: auto; min-height: 18rem; padding: 0; overflow: hidden; resize: vertical; border: 0; outline: 0; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; tab-size: 4; transition-property: box-shadow; transition-duration: 150ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); } - textarea:focus { box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } + textarea:focus-visible { outline: 3px solid var(--accent-text); outline-offset: 3px; box-shadow: 0 0 0 6px var(--accent-soft); } .textarea-fallback[hidden] { display: none; } .cm-editor { flex: 1; min-height: 18rem; background: transparent; color: var(--text); font: 14px/1.5 ui-monospace, SF Mono, Fira Code, Consolas, monospace; } - .cm-editor.cm-focused { outline: 0; box-shadow: 0 0 0 3px rgba(255, 72, 1, 0.18); } - .cm-scroller { font-family: inherit; line-height: 1.5; } + .cm-editor.cm-focused { outline: 3px solid var(--accent-text); outline-offset: 3px; box-shadow: 0 0 0 6px var(--accent-soft); } + .cm-scroller { font-family: inherit; line-height: 1.5; scrollbar-color: var(--accent-text) var(--surface-3); scrollbar-width: thin; } + .cm-scroller::-webkit-scrollbar { width: 10px; height: 10px; } + .cm-scroller::-webkit-scrollbar-track { background: var(--surface-3); } + .cm-scroller::-webkit-scrollbar-thumb { border: 2px solid var(--surface-3); border-radius: 999px; background: var(--accent-text); } .cm-content { padding: 0; } .cm-line { padding: 0; } .cm-gutters { background: transparent; border-right: 1px solid var(--hairline-soft); color: var(--muted); font-variant-numeric: tabular-nums; } @@ -69,12 +74,13 @@ } .site-search { position: relative; width: 100%; max-width: none; margin: 0 0 var(--space-5); } .site-search input { width: 100%; min-height: 44px; padding: .6rem 1rem; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); color: var(--text); font: inherit; transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } - .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + .site-search input:focus-visible { outline: 3px solid var(--accent-text); outline-offset: 2px; border-color: var(--accent-text); box-shadow: 0 0 0 6px var(--accent-soft); } .site-search input::placeholder { color: var(--muted); } + .search-status { min-height: 1.4em; margin: .35rem 0 0; color: var(--muted); font-size: .88rem; } .search-results { position: absolute; inset-inline: 0; top: 100%; z-index: 3; margin: .35rem 0 0; padding: .35rem; list-style: none; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); box-shadow: 0 12px 42px rgba(0, 0, 0, .14); } .search-results li { margin: 0; } .search-results a { display: block; padding: .5rem .65rem; border-radius: .5rem; color: var(--text); text-decoration: none; } - .search-results a:hover, .search-results a:focus { background: var(--accent-soft); outline: 0; } + .search-results a:hover, .search-results a:focus-visible { background: var(--accent-soft); outline: 3px solid var(--accent-text); outline-offset: -1px; } .search-results .search-empty { padding: .5rem .65rem; color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } .home-section { margin-top: var(--space-6); } @@ -242,8 +248,7 @@ --header-veil-solid: rgba(27, 18, 11, 0.95); } .cell-banner figure svg, .journey-section-figure svg { padding: var(--space-2); border-radius: .5rem; background: var(--figure-paper); } - .button { color: #FFF7EF; } - .shiki, .shiki span { color: var(--shiki-dark) !important; } + .button { color: #2A0B00; } } /* Non-motion accessibility signals. Reduced motion is handled by the transition kill switch and no-preference gates above; these diff --git a/public/syntax-highlight.d6178fb3eef5.js b/public/syntax-highlight.c405e604fc10.js similarity index 52% rename from public/syntax-highlight.d6178fb3eef5.js rename to public/syntax-highlight.c405e604fc10.js index aa99729..4f9b866 100644 --- a/public/syntax-highlight.d6178fb3eef5.js +++ b/public/syntax-highlight.c405e604fc10.js @@ -1,7 +1,7 @@ // Copy affordance for read-only source blocks. The button attaches to -// the .cell-source wrapper (not the
) so the Shiki replacement
-// below leaves it in place, and it is injected client-side so no-JS
-// pages render no dead buttons.
+// the .cell-source wrapper (not the 
) so Shiki replacement leaves it
+// in place, and it is injected client-side so no-JS pages render no dead
+// controls.
 async function copyText(text) {
   if (navigator.clipboard && window.isSecureContext) {
     await navigator.clipboard.writeText(text);
@@ -42,7 +42,7 @@ for (const source of document.querySelectorAll('.cell-source')) {
       await copyText(code.textContent);
       button.classList.add('copied');
       status.textContent = 'Copied';
-    } catch (error) {
+    } catch (_) {
       button.classList.add('failed');
       status.textContent = 'Copy failed';
     }
@@ -54,33 +54,42 @@ for (const source of document.querySelectorAll('.cell-source')) {
   source.append(button);
 }
 
-// Shiki is imported dynamically so a CDN failure degrades to plain
-// server-rendered code while the copy buttons above keep working.
-let codeToHtml = null;
-try {
-  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
-} catch (error) {
-  codeToHtml = null;
-}
-
-const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
+// Read-only Python cells are the only pages that need Shiki. Avoid even
+// requesting the CDN module on the home, About, and other code-free pages.
+const sourceBlocks = [...document.querySelectorAll('pre code.language-python')].map((code) => ({
+  source: code.textContent,
+  pre: code.closest('pre'),
+}));
 
-for (const block of blocks) {
-  const source = block.textContent;
+if (sourceBlocks.length) {
+  let codeToHtml = null;
   try {
-    const highlighted = await codeToHtml(source, {
-      lang: 'python',
-      themes: { light: 'github-light', dark: 'github-dark' },
-      defaultColor: 'light',
-    });
-    const wrapper = document.createElement('div');
-    wrapper.innerHTML = highlighted;
-    const shikiPre = wrapper.firstElementChild;
-    if (!shikiPre) continue;
-    shikiPre.classList.add('shiki-block');
-    const currentPre = block.closest('pre');
-    currentPre.replaceWith(shikiPre);
-  } catch (error) {
-    block.dataset.highlightFailed = 'true';
+    ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+  } catch (_) {
+    // Plain server-rendered code remains readable when the optional CDN fails.
+  }
+
+  if (codeToHtml) {
+    const themePreference = window.matchMedia('(prefers-color-scheme: dark)');
+    const highlight = async () => {
+      const theme = themePreference.matches ? 'github-dark' : 'github-light';
+      for (const block of sourceBlocks) {
+        try {
+          const highlighted = await codeToHtml(block.source, { lang: 'python', theme });
+          const wrapper = document.createElement('div');
+          wrapper.innerHTML = highlighted;
+          const shikiPre = wrapper.firstElementChild;
+          if (!shikiPre || !block.pre?.isConnected) continue;
+          shikiPre.classList.add('shiki-block');
+          block.pre.replaceWith(shikiPre);
+          block.pre = shikiPre;
+        } catch (_) {
+          block.pre?.setAttribute('data-highlight-failed', 'true');
+        }
+      }
+    };
+
+    await highlight();
+    themePreference.addEventListener?.('change', highlight);
   }
 }
diff --git a/public/syntax-highlight.js b/public/syntax-highlight.js
index aa99729..4f9b866 100644
--- a/public/syntax-highlight.js
+++ b/public/syntax-highlight.js
@@ -1,7 +1,7 @@
 // Copy affordance for read-only source blocks. The button attaches to
-// the .cell-source wrapper (not the 
) so the Shiki replacement
-// below leaves it in place, and it is injected client-side so no-JS
-// pages render no dead buttons.
+// the .cell-source wrapper (not the 
) so Shiki replacement leaves it
+// in place, and it is injected client-side so no-JS pages render no dead
+// controls.
 async function copyText(text) {
   if (navigator.clipboard && window.isSecureContext) {
     await navigator.clipboard.writeText(text);
@@ -42,7 +42,7 @@ for (const source of document.querySelectorAll('.cell-source')) {
       await copyText(code.textContent);
       button.classList.add('copied');
       status.textContent = 'Copied';
-    } catch (error) {
+    } catch (_) {
       button.classList.add('failed');
       status.textContent = 'Copy failed';
     }
@@ -54,33 +54,42 @@ for (const source of document.querySelectorAll('.cell-source')) {
   source.append(button);
 }
 
-// Shiki is imported dynamically so a CDN failure degrades to plain
-// server-rendered code while the copy buttons above keep working.
-let codeToHtml = null;
-try {
-  ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
-} catch (error) {
-  codeToHtml = null;
-}
-
-const blocks = codeToHtml ? document.querySelectorAll('pre code.language-python') : [];
+// Read-only Python cells are the only pages that need Shiki. Avoid even
+// requesting the CDN module on the home, About, and other code-free pages.
+const sourceBlocks = [...document.querySelectorAll('pre code.language-python')].map((code) => ({
+  source: code.textContent,
+  pre: code.closest('pre'),
+}));
 
-for (const block of blocks) {
-  const source = block.textContent;
+if (sourceBlocks.length) {
+  let codeToHtml = null;
   try {
-    const highlighted = await codeToHtml(source, {
-      lang: 'python',
-      themes: { light: 'github-light', dark: 'github-dark' },
-      defaultColor: 'light',
-    });
-    const wrapper = document.createElement('div');
-    wrapper.innerHTML = highlighted;
-    const shikiPre = wrapper.firstElementChild;
-    if (!shikiPre) continue;
-    shikiPre.classList.add('shiki-block');
-    const currentPre = block.closest('pre');
-    currentPre.replaceWith(shikiPre);
-  } catch (error) {
-    block.dataset.highlightFailed = 'true';
+    ({ codeToHtml } = await import('https://esm.sh/shiki@1.29.2'));
+  } catch (_) {
+    // Plain server-rendered code remains readable when the optional CDN fails.
+  }
+
+  if (codeToHtml) {
+    const themePreference = window.matchMedia('(prefers-color-scheme: dark)');
+    const highlight = async () => {
+      const theme = themePreference.matches ? 'github-dark' : 'github-light';
+      for (const block of sourceBlocks) {
+        try {
+          const highlighted = await codeToHtml(block.source, { lang: 'python', theme });
+          const wrapper = document.createElement('div');
+          wrapper.innerHTML = highlighted;
+          const shikiPre = wrapper.firstElementChild;
+          if (!shikiPre || !block.pre?.isConnected) continue;
+          shikiPre.classList.add('shiki-block');
+          block.pre.replaceWith(shikiPre);
+          block.pre = shikiPre;
+        } catch (_) {
+          block.pre?.setAttribute('data-highlight-failed', 'true');
+        }
+      }
+    };
+
+    await highlight();
+    themePreference.addEventListener?.('change', highlight);
   }
 }
diff --git a/pyproject.toml b/pyproject.toml
index dab24f6..e85073c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,6 +14,7 @@ workers = [
     "workers-runtime-sdk",
 ]
 dev = [
+    "pillow>=11.3,<12",
     "ruff>=0.8",
 ]
 
diff --git a/scripts/build_social_cards.py b/scripts/build_social_cards.py
index a89ff8e..d2001c2 100644
--- a/scripts/build_social_cards.py
+++ b/scripts/build_social_cards.py
@@ -15,6 +15,8 @@
 import sys
 from pathlib import Path
 
+from PIL import Image, UnidentifiedImageError
+
 ROOT = Path(__file__).resolve().parents[1]
 sys.path.insert(0, str(ROOT))
 
@@ -110,6 +112,19 @@ def provenance(cards: dict[str, str]) -> dict[str, object]:
     return {"version": PROVENANCE_VERSION, "inputs": {f"{name}.jpg": hashlib.sha256(source.encode()).hexdigest() for name, source in sorted(cards.items())}}
 
 
+def jpeg_dimensions(path: Path) -> tuple[int, int]:
+    """Decode enough of a JPEG to verify integrity and return its dimensions."""
+    try:
+        with Image.open(path) as image:
+            if image.format != "JPEG":
+                raise ValueError(f"expected JPEG format, got {image.format or 'unknown'}")
+            dimensions = image.size
+            image.load()
+    except (OSError, UnidentifiedImageError) as exc:
+        raise ValueError(str(exc)) from exc
+    return dimensions
+
+
 def check_current(cards: dict[str, str]) -> list[str]:
     failures: list[str] = []
     expected = provenance(cards)
@@ -125,6 +140,17 @@ def check_current(cards: dict[str, str]) -> list[str]:
         failures.append(f"missing social card {name}; run make social-cards")
     for name in sorted(actual_files - expected_files):
         failures.append(f"unexpected stale social card {name}; run make social-cards")
+    for name in sorted(expected_files & actual_files):
+        try:
+            dimensions = jpeg_dimensions(OUTPUT_DIR / name)
+        except (OSError, ValueError) as exc:
+            failures.append(f"invalid JPEG social card {name}: {exc}; run make social-cards")
+            continue
+        if dimensions != (CARD_WIDTH, CARD_HEIGHT):
+            failures.append(
+                f"social card {name} is {dimensions[0]}x{dimensions[1]}, "
+                f"expected {CARD_WIDTH}x{CARD_HEIGHT}; run make social-cards"
+            )
     return failures
 
 
diff --git a/scripts/capture_browser_screenshot.mjs b/scripts/capture_browser_screenshot.mjs
index cff96cc..78dc3a5 100755
--- a/scripts/capture_browser_screenshot.mjs
+++ b/scripts/capture_browser_screenshot.mjs
@@ -14,6 +14,8 @@ const url = `${target}${target.includes('?') ? '&' : '?'}screenshot_test=${Date.
 const width = Number(process.env.WIDTH || 2040);
 const height = Number(process.env.HEIGHT || 626);
 const captureSelector = process.env.CAPTURE_SELECTOR || '.literate-program';
+const colorScheme = process.env.COLOR_SCHEME || '';
+const focusSelector = process.env.FOCUS_SELECTOR || '';
 const port = 9444 + Math.floor(Math.random() * 1000);
 const profile = await mkdtemp(path.join(tmpdir(), 'pythonbyexample-shot-chrome-'));
 
@@ -82,6 +84,12 @@ try {
   await client.send('Network.setCacheDisabled', { cacheDisabled: true });
   await client.send('Page.enable');
   await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
+  if (colorScheme) {
+    await client.send('Emulation.setEmulatedMedia', {
+      media: 'screen',
+      features: [{ name: 'prefers-color-scheme', value: colorScheme }],
+    });
+  }
   await client.send('Page.navigate', { url });
 
   for (let i = 0; i < 100; i++) {
@@ -93,6 +101,13 @@ try {
     await sleep(100);
   }
 
+  if (focusSelector) {
+    await client.send('Runtime.evaluate', {
+      expression: `document.querySelector(${JSON.stringify(focusSelector)})?.focus()`,
+    });
+    await sleep(100);
+  }
+
   if (args.has('--legacy-shiki')) {
     await client.send('Runtime.evaluate', {
       awaitPromise: true,
diff --git a/scripts/check_browser_layout.mjs b/scripts/check_browser_layout.mjs
index 8286ee5..38d476e 100755
--- a/scripts/check_browser_layout.mjs
+++ b/scripts/check_browser_layout.mjs
@@ -182,10 +182,50 @@ try {
     throw new Error(`Timed out waiting for ${label}`);
   }
 
-  // Exercise the public browser contracts rather than merely asserting source
-  // strings: copy text, fragment sharing/restoration, resize, and arrow guards.
+  // Exercise public browser contracts rather than asserting implementation
+  // strings: run ordering/reset, sharing bounds, copy, resize, and arrow guards.
   await client.send('Page.navigate', { url: target });
   await waitFor("document.readyState === 'complete' && !!window.pythonByExampleEditor && !!document.querySelector('.copy-button') && !!document.querySelector('.share-button')", 'feature test page');
+  const runnerAsset = await evaluateValue(`document.querySelector('script[src*="/runner."]')?.src`);
+  const runnerOrdering = await evaluateValue(`(async () => {
+    const form = document.querySelector('form.runner-editor');
+    const output = document.querySelector('.output-panel code');
+    const pending = [];
+    const originalFetch = window.fetch;
+    window.fetch = () => new Promise(resolve => pending.push(resolve));
+    const response = text => ({
+      ok: true,
+      status: 200,
+      text: async () => '

Output

' + text + '
', + }); + window.pythonByExampleEditor.setValue('print("first")'); + form.requestSubmit(); + await new Promise(resolve => setTimeout(resolve, 0)); + window.pythonByExampleEditor.setValue('print("second")'); + form.requestSubmit(); + await new Promise(resolve => setTimeout(resolve, 0)); + if (pending.length !== 2) { + window.fetch = originalFetch; + return { error: 'concurrent submissions did not create two requests', count: pending.length }; + } + pending[1](response('second result')); + await new Promise(resolve => setTimeout(resolve, 0)); + pending[0](response('stale first result')); + await new Promise(resolve => setTimeout(resolve, 20)); + const finalOutput = document.querySelector('.output-panel code')?.textContent; + const reset = form.querySelector('[data-reset]'); + reset.click(); + await new Promise(resolve => setTimeout(resolve, 0)); + const resetValue = document.getElementById('code-editor').value; + window.fetch = originalFetch; + return { + finalOutput, + resetType: reset.type, + resetValue, + originalCode: document.getElementById('code-editor').dataset.originalCode, + busy: form.hasAttribute('aria-busy'), + }; + })()`); const share = await evaluateValue(`(async () => { const writes = []; window.__pbeClipboardWrites = writes; @@ -265,8 +305,63 @@ try { searchWidths.push(await searchWidthAt('desktop', 1200, 900, false)); searchWidths.push(await searchWidthAt('mobile-portrait', 390, 844, true)); searchWidths.push(await searchWidthAt('mobile-landscape', 844, 390, true)); + const searchFailure = await evaluateValue(`(async () => { + const originalFetch = window.fetch; + window.fetch = async () => ({ ok: false, status: 503, json: async () => ({}) }); + const input = document.getElementById('site-search-input'); + input.focus(); + input.value = 'decorator'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise(resolve => setTimeout(resolve, 50)); + const state = { + status: document.getElementById('site-search-status')?.textContent, + outlineWidth: getComputedStyle(input).outlineWidth, + shikiRequests: performance.getEntriesByType('resource').filter(entry => entry.name.includes('esm.sh/shiki')).length, + }; + window.fetch = originalFetch; + return state; + })()`); await client.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 1800, deviceScaleFactor: 2, mobile: true }); + // Theme changes are a live OS preference, not a reload-only setting. + await client.send('Emulation.setEmulatedMedia', { + media: 'screen', + features: [{ name: 'prefers-color-scheme', value: 'light' }], + }); + await client.send('Page.navigate', { url: `${target}?browser_theme=light` }); + await waitFor("document.readyState === 'complete' && !!document.querySelector('.shiki-block') && !!document.querySelector('.cm-editor')", 'light theme page'); + const lightTheme = await evaluateValue(`({ + shikiColor: getComputedStyle(document.querySelector('.shiki-block span')).color, + editorColors: [...new Set([...document.querySelectorAll('.cm-line span')].map(node => getComputedStyle(node).color))].sort(), + })`); + await client.send('Emulation.setEmulatedMedia', { + media: 'screen', + features: [{ name: 'prefers-color-scheme', value: 'dark' }], + }); + await waitFor(`getComputedStyle(document.querySelector('.shiki-block span')).color !== ${JSON.stringify(lightTheme.shikiColor)}`, 'live dark-theme Shiki rerender'); + const darkTheme = await evaluateValue(`(() => { + const parse = color => color.match(/[\\d.]+/g).slice(0, 3).map(Number); + const luminance = color => { + const channels = parse(color).map(value => { + const normalized = value / 255; + return normalized <= .04045 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4; + }); + return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2]; + }; + const button = document.querySelector('button[type="submit"]'); + const style = getComputedStyle(button); + const a = luminance(style.color); + const b = luminance(style.backgroundColor); + document.querySelector('.cm-content').focus(); + const editorStyle = getComputedStyle(document.querySelector('.cm-editor')); + return { + shikiColor: getComputedStyle(document.querySelector('.shiki-block span')).color, + editorColors: [...new Set([...document.querySelectorAll('.cm-line span')].map(node => getComputedStyle(node).color))].sort(), + runContrast: (Math.max(a, b) + .05) / (Math.min(a, b) + .05), + editorOutlineWidth: editorStyle.outlineWidth, + }; + })()`); + // Keep esm.sh import requests pending. The runner has no imports and is // emitted after its markup, so its controls must attach before the editor's // CDN graph can let DOMContentLoaded fire. @@ -284,6 +379,90 @@ try { removePauseListener(); await client.send('Fetch.disable'); + const oversizedUrl = new URL(target); + oversizedUrl.searchParams.set('browser_oversized_fragment', Date.now()); + oversizedUrl.hash = `code=${'A'.repeat(24001)}`; + await client.send('Page.navigate', { url: oversizedUrl.href }); + await waitFor("!!document.querySelector('.share-button')", 'oversized-fragment page'); + const fragmentBound = await evaluateValue(`({ + unchanged: document.getElementById('code-editor').value === document.getElementById('code-editor').dataset.originalCode, + notice: document.querySelector('.output-panel code')?.textContent, + })`); + + // Build a dependency-free runner fixture on a code-free page. The first + // Turnstile script load fails; the second must create a fresh script, + // execute with the fixed action, and submit the returned token. + await client.send('Page.navigate', { url: `${new URL(target).origin}/?browser_turnstile_retry=${Date.now()}` }); + await waitFor("document.readyState === 'complete'", 'Turnstile retry fixture page'); + const turnstileRetry = await evaluateValue(`(async () => { + document.body.innerHTML = \` +
+
+ + +
+ + +
+
+

Output

Ready
+
\`; + const nativeAppend = document.head.appendChild.bind(document.head); + const nativeFetch = window.fetch; + let scriptAttempts = 0; + let renderAction = ''; + let widgetOptions = null; + document.head.appendChild = node => { + if (node.tagName === 'SCRIPT' && node.src.includes('challenges.cloudflare.com/turnstile')) { + scriptAttempts += 1; + setTimeout(() => { + if (scriptAttempts === 1) { + node.onerror?.(new Event('error')); + return; + } + window.turnstile = { + render: (_box, options) => { widgetOptions = options; renderAction = options.action; return 7; }, + execute: () => queueMicrotask(() => widgetOptions.callback('retry-token')), + remove: () => {}, + reset: () => {}, + }; + node.onload?.(new Event('load')); + }, 0); + return node; + } + return nativeAppend(node); + }; + let fetchCount = 0; + let submittedToken = ''; + window.fetch = async (_url, options) => { + fetchCount += 1; + submittedToken = options.body.get('cf-turnstile-response') || submittedToken; + const html = fetchCount < 3 + ? '
Verification required
' + : '

Output

retry succeeded
'; + return { ok: true, status: 200, text: async () => html }; + }; + await import(${JSON.stringify(runnerAsset)} + '?retry=' + Date.now()); + const form = document.querySelector('form.runner-editor'); + const output = () => document.querySelector('.output-panel code')?.textContent || ''; + const waitUntil = async predicate => { + for (let i = 0; i < 100; i++) { + if (predicate()) return true; + await new Promise(resolve => setTimeout(resolve, 10)); + } + return false; + }; + form.requestSubmit(); + await waitUntil(() => output().includes('press Run to retry')); + const firstFailure = output(); + form.requestSubmit(); + await waitUntil(() => output().includes('retry succeeded')); + const finalOutput = output(); + document.head.appendChild = nativeAppend; + window.fetch = nativeFetch; + return { scriptAttempts, fetchCount, renderAction, submittedToken, firstFailure, finalOutput }; + })()`); + const failures = []; if (interaction.result.value?.ariaLabel !== 'Editable Python example code') { failures.push('CodeMirror editor is missing its accessible name'); @@ -295,6 +474,9 @@ try { if (block.ratio > 1.25) failures.push(`block ${block.index} visual line height ratio ${block.ratio.toFixed(2)} > 1.25`); } if (!metrics.loadedShiki) failures.push('Shiki did not load'); + if (runnerOrdering.error) failures.push(runnerOrdering.error); + if (runnerOrdering.finalOutput !== 'second result') failures.push(`Stale run overwrote the latest output (${runnerOrdering.finalOutput})`); + if (runnerOrdering.resetType !== 'reset' || runnerOrdering.resetValue !== runnerOrdering.originalCode || runnerOrdering.busy) failures.push('Native Reset did not restore code and cancel runner state'); if (!share.url.includes('#code=')) failures.push('Copy link did not copy a shared-code fragment'); if (restored.notice !== 'This link included edited code. Press Run to execute it.') failures.push('Shared-code recipient notice did not render'); if (restored.editorHeight < 1000) failures.push(`Shared CodeMirror payload did not resize the editor (${restored.editorHeight}px)`); @@ -309,18 +491,34 @@ try { failures.push(`${measurement.label} search is not full width (${measurement.searchWidth}px search, ${measurement.mainWidth}px main, ${measurement.inputWidth}px input)`); } } + if (!searchFailure.status?.includes('temporarily unavailable')) failures.push('Search fetch failure was not announced'); + if (Number.parseFloat(searchFailure.outlineWidth) < 3) failures.push('Search focus indicator is too weak'); + if (searchFailure.shikiRequests !== 0) failures.push('Code-free homepage unnecessarily loaded Shiki'); + if (lightTheme.shikiColor === darkTheme.shikiColor) failures.push('Shiki did not react to the OS theme change'); + if (JSON.stringify(lightTheme.editorColors) === JSON.stringify(darkTheme.editorColors)) failures.push('CodeMirror did not react to the OS theme change'); + if (darkTheme.runContrast < 4.5) failures.push(`Dark Run-button contrast is ${darkTheme.runContrast.toFixed(2)}:1`); + if (Number.parseFloat(darkTheme.editorOutlineWidth) < 3) failures.push('CodeMirror focus indicator is too weak'); if (heldCdnRequests === 0) failures.push('CDN-pending runner test did not hold an esm.sh request'); if (offlineRunner.editorLoaded) failures.push('CDN-pending test unexpectedly loaded CodeMirror'); if (offlineRunner.fallbackHeight < 1000) failures.push(`Shared fallback textarea did not resize (${offlineRunner.fallbackHeight}px)`); + if (!fragmentBound.unchanged || !fragmentBound.notice?.includes('invalid or too large')) failures.push('Oversized shared-code fragment was decoded or not announced'); + if (turnstileRetry.scriptAttempts !== 2 || !turnstileRetry.firstFailure.includes('press Run to retry') || turnstileRetry.finalOutput !== 'retry succeeded') failures.push('Transient Turnstile CDN failure was cached instead of retried'); + if (turnstileRetry.renderAction !== 'run-example' || turnstileRetry.submittedToken !== 'retry-token') failures.push('Turnstile browser action/token contract failed'); console.log(JSON.stringify({ runnerInteraction: interaction.result.value, + runnerOrdering, share: { codeLength: share.code.length, urlLength: share.url.length, editorHeight: share.editorHeight }, restored, sourceCopy: { copiedLength: sourceCopy.copied.length, state: sourceCopy.state, status: sourceCopy.status }, arrow, searchWidths, + searchFailure, + lightTheme, + darkTheme, offlineRunner, + fragmentBound, + turnstileRetry, heldCdnRequests, }, null, 2)); client.close(); diff --git a/src/app.py b/src/app.py index 93059b9..cd3d3a7 100644 --- a/src/app.py +++ b/src/app.py @@ -109,7 +109,22 @@ def write(self, value): return len(value) def getvalue(self): - return "".join(self.parts) + # Submitted code receives sys.stdout and can mutate its attributes + # directly. Revalidate the sink after exec before joining anything, + # so appending around write() cannot bypass the byte cap. + if type(self.parts) is not list: + raise OutputLimitExceeded + checked = [] + total = 0 + for value in self.parts: + if type(value) is not str or len(value) > MAX_OUTPUT_BYTES - total: + raise OutputLimitExceeded + encoded = value.encode("utf-8") + total += len(encoded) + if total > MAX_OUTPUT_BYTES: + raise OutputLimitExceeded + checked.append(value) + return "".join(checked) class Default(WorkerEntrypoint): @@ -119,7 +134,7 @@ async def fetch(self, request): try: with contextlib.redirect_stdout(stdout): exec(EXAMPLE_CODE, namespace) - return Response(stdout.getvalue(), headers={{"Content-Type": "text/plain; charset=utf-8"}}) + return Response(BoundedStdout.getvalue(stdout), headers={{"Content-Type": "text/plain; charset=utf-8"}}) except OutputLimitExceeded: return Response(OUTPUT_LIMIT_MESSAGE, status=413, headers={{"Content-Type": "text/plain; charset=utf-8"}}) except Exception: @@ -197,9 +212,12 @@ def _structured_data_script(data: dict) -> str: return f'' -def _layout(title: str, content: str, description: str | None = None, path: str = "/", og_type: str = "website", include_editor: bool = False, include_search: bool = False, structured_data: dict | None = None, og_image: str | None = None) -> str: +def _layout(title: str, content: str, description: str | None = None, path: str | None = "/", og_type: str = "website", include_editor: bool = False, include_search: bool = False, structured_data: dict | None = None, og_image: str | None = None) -> str: description = _meta_description(description or "Learn Python with concise, editable examples that run in isolated Cloudflare Dynamic Python Workers.") - canonical_url = f"{SITE_URL}{path}" + canonical_url = f"{SITE_URL}{path}" if path is not None else "" + canonical_tag = f'' if canonical_url else "" + og_url_tag = f'' if canonical_url else "" + robots_meta = "" if canonical_url else '' page_title = title if title == "Python By Example" else f"{title} · Python By Example" # The CDN-backed editor stays in the head, but the dependency-free runner # is emitted after page content. Its async module can then attach Run, @@ -219,7 +237,9 @@ def _layout(title: str, content: str, description: str | None = None, path: str "REFERENCE_URL": html.escape(REFERENCE_URL), "PYTHON_VERSION": html.escape(PYTHON_VERSION), "META_DESCRIPTION": html.escape(description), - "CANONICAL_URL": html.escape(canonical_url), + "CANONICAL_TAG": canonical_tag, + "OG_URL_TAG": og_url_tag, + "ROBOTS_META": robots_meta, "OG_TYPE": html.escape(og_type), "SITE_CSS": html.escape(ASSET_PATHS["SITE_CSS"]), "SYNTAX_JS": html.escape(ASSET_PATHS["SYNTAX_JS"]), @@ -426,15 +446,15 @@ def render_example_not_found(slug: str) -> str: '

Try one of these nearby examples.

' f"

Recommended examples

    {recommendations}
" ) - return _layout("Not Found", content) + return _layout("Not Found", content, path=None) def render_journey_not_found() -> str: - return _layout("Not Found", "

Journey not found

") + return _layout("Not Found", "

Journey not found

", path=None) def render_not_found() -> str: - return _layout("Not Found", "

Not found

") + return _layout("Not Found", "

Not found

", path=None) def _example_neighbors(slug): @@ -692,6 +712,12 @@ def render_example_page( def route(url: str, method: str = "GET", turnstile_site_key: str | None = None) -> AppResponse: + """Legacy pure-rendering adapter retained for focused renderer tests. + + Production HTTP routing, middleware, caching, POST handling, and security + headers live in ``src.main``. New transport behavior must be tested through + that ASGI application rather than added here. + """ without_scheme = url.split("://", 1)[-1] path_part = without_scheme.split("/", 1)[1] if "/" in without_scheme else "" path = ("/" + path_part.split("?", 1)[0]).rstrip("/") or "/" @@ -719,7 +745,7 @@ def route(url: str, method: str = "GET", turnstile_site_key: str | None = None) slug = path.split("/", 2)[2] journey = JOURNEYS_BY_SLUG.get(slug) if journey is None: - return AppResponse(_layout("Not Found", "

Journey not found

"), status=404) + return AppResponse(_layout("Not Found", "

Journey not found

", path=None), status=404) return AppResponse(render_journey_page(journey), headers={"Content-Type": "text/html; charset=utf-8"}) if path.startswith("/examples/"): slug = path.split("/", 2)[2] @@ -730,9 +756,9 @@ def route(url: str, method: str = "GET", turnstile_site_key: str | None = None) for item in _recommended_examples(slug) ) body = f'

Example not found

Try one of these nearby examples.

Recommended examples

    {recommendations}
' - return AppResponse(_layout("Not Found", body), status=404) + return AppResponse(_layout("Not Found", body, path=None), status=404) return AppResponse( render_example_page(example, turnstile_site_key=turnstile_site_key), headers={"Content-Type": "text/html; charset=utf-8"}, ) - return AppResponse(_layout("Not Found", "

Not found

"), status=404) + return AppResponse(_layout("Not Found", "

Not found

", path=None), status=404) diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 18c2f38..f269ba4 100644 --- a/src/asset_manifest.py +++ b/src/asset_manifest.py @@ -1,3 +1,3 @@ # Generated by scripts/fingerprint_assets.py. Do not edit by hand. -ASSET_PATHS = {'SITE_CSS': '/site.4ad21a6ce8a3.css', 'SYNTAX_JS': '/syntax-highlight.d6178fb3eef5.js', 'EDITOR_JS': '/editor.a550bf2266bd.js', 'RUNNER_JS': '/runner.d3515b57bc3e.js', 'SEARCH_JS': '/search.158bd7afa114.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'} -HTML_CACHE_VERSION = '9ae21c5f386e' +ASSET_PATHS = {'SITE_CSS': '/site.07c59ae07f87.css', 'SYNTAX_JS': '/syntax-highlight.c405e604fc10.js', 'EDITOR_JS': '/editor.3827f6497b54.js', 'RUNNER_JS': '/runner.96b105eef218.js', 'SEARCH_JS': '/search.e5a5822d9917.js', 'SEARCH_INDEX': '/search-index.332712b2e502.json'} +HTML_CACHE_VERSION = '3f7b7d26c98f' diff --git a/src/main.py b/src/main.py index 09724b9..7a59b8c 100644 --- a/src/main.py +++ b/src/main.py @@ -12,8 +12,10 @@ from workers import WorkerEntrypoint, python_from_rpc from app import ( + DYNAMIC_OUTPUT_LIMIT_MESSAGE, FAVICON_SVG, JOURNEYS_BY_SLUG, + MAX_DYNAMIC_OUTPUT_BYTES, build_dynamic_worker_code, get_example, render_about, @@ -36,6 +38,7 @@ import worker_asgi_bridge as asgi TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" +TURNSTILE_ACTION = "run-example" SMOKE_BYPASS_HEADER = "x-pythonbyexample-smoke-secret" TURNSTILE_CLEARANCE_COOKIE = "pbe_turnstile_clearance" DEFAULT_TURNSTILE_CLEARANCE_SECONDS = 60 * 60 * 8 @@ -344,6 +347,7 @@ async def run_example(slug: str, request: Request): elif event := _wide_event(request): event["turnstile"] = {"outcome": _turnstile_not_required_outcome(request)} + request.state.dynamic_worker_status = 200 started = time.perf_counter() try: output = await _run_example(request, example["slug"], submitted) @@ -358,7 +362,8 @@ async def run_example(slug: str, request: Request): code=submitted, execution_time_ms=elapsed_ms, turnstile_site_key=_turnstile_site_key(request), - ) + ), + status=413 if request.state.dynamic_worker_status == 413 else 200, ) if turnstile_verified: _set_turnstile_clearance(response, request) @@ -411,11 +416,50 @@ async def _verify_turnstile(request: Request, token: str) -> tuple[bool, str, st raise ValueError("Turnstile Siteverify returned a non-object payload") except Exception: return False, "Turnstile verification failed. Please refresh the challenge and try again.", "fail" - if result.get("success") is True: + expected_hostname = urlparse(str(request.url)).hostname or "" + if ( + result.get("success") is True + and result.get("hostname", "").lower().rstrip(".") == expected_hostname.lower().rstrip(".") + and result.get("action") == TURNSTILE_ACTION + ): return True, "", "pass" return False, "Turnstile verification failed. Please refresh the challenge and try again.", "fail" +async def _read_dynamic_response_text(response) -> tuple[str, bool]: + """Read a Dynamic Worker response without trusting its declared status. + + Submitted code controls the child runtime and may tamper with Python-level + stdout internals. The parent therefore enforces the same byte limit while + consuming the Fetch response stream and cancels as soon as it is exceeded. + """ + body = getattr(response, "body", None) + if body is not None and hasattr(body, "getReader"): + reader = body.getReader() + chunks: list[bytes] = [] + total = 0 + while True: + result = await reader.read() + if bool(getattr(result, "done", False)): + break + value = getattr(result, "value", None) + chunk = value.to_bytes() if hasattr(value, "to_bytes") else bytes(value) + total += len(chunk) + if total > MAX_DYNAMIC_OUTPUT_BYTES: + try: + await reader.cancel("Dynamic Worker output exceeded limit") + except Exception: + pass + return DYNAMIC_OUTPUT_LIMIT_MESSAGE, True + chunks.append(chunk) + return b"".join(chunks).decode("utf-8", errors="replace"), False + + output = await response.text() + if len(output.encode("utf-8")) > MAX_DYNAMIC_OUTPUT_BYTES: + return DYNAMIC_OUTPUT_LIMIT_MESSAGE, True + return output, False + + async def _run_example(request: Request, slug, code): event = _wide_event(request) if event is None: @@ -466,8 +510,11 @@ def provide_worker_code(): ) response = python_from_rpc(await entrypoint.fetch(dynamic_request)) status_code = int(getattr(response, "status", 200) or 200) + output, output_limit_exceeded = await _read_dynamic_response_text(response) + if output_limit_exceeded: + status_code = 413 worker_event["status_code"] = status_code - output = await response.text() + request.state.dynamic_worker_status = status_code if status_code == 413: worker_event["outcome"] = "rejected" worker_event["rejected"] = "output_too_large" @@ -553,6 +600,7 @@ async def fetch(self, request): max_body_bytes=MAX_SUBMITTED_BODY_BYTES, ) _apply_security_headers(response) + observability.record_status(event, getattr(response, "status", 200)) if getattr(response, "status", 200) == 200: response.headers.set( "Cache-Control", @@ -572,6 +620,7 @@ async def fetch(self, request): max_body_bytes=MAX_SUBMITTED_BODY_BYTES, ) _apply_security_headers(response) + observability.record_status(event, getattr(response, "status", 200)) response.headers.set("Cache-Control", "no-store") return response @@ -584,6 +633,8 @@ async def fetch(self, request): max_body_bytes=MAX_SUBMITTED_BODY_BYTES, ) _apply_security_headers(response) + observability.record_status(event, getattr(response, "status", 200)) + response.headers.set("Cache-Control", "no-store") return response except Exception as exc: event["status_code"] = 500 diff --git a/src/marginalia.py b/src/marginalia.py index cdba872..187054d 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -1,7 +1,8 @@ """Marginalia attachments and figure registry. -Authors of example markdown never touch this file. The project owner curates -named figures here and attaches them to cells via slug + anchor. +Example prose and code live in Markdown; illustration definitions and their +slug/anchor attachments are curated here. Figure quality metadata lives in the +``[figures]`` table of ``docs/quality-registries.toml``. Anchors today: "cell-0", "cell-1", … each literate-program cell, zero-indexed. diff --git a/src/templates/about.html b/src/templates/about.html index a007c4f..5aff8d7 100644 --- a/src/templates/about.html +++ b/src/templates/about.html @@ -15,7 +15,7 @@

Every page runs

Output is verified, not promised

-

The expected output beside each fragment is not written by hand. A build step executes every teaching cell and every complete program, then fails if the recorded output differs from what actually printed. Cells the sandbox cannot execute are labelled as standard Python instead of pretending to run.

+

Expected output is authored in each example's Markdown source and rendered beside the corresponding fragment. The build executes every supported teaching cell and complete program, then fails if captured stdout differs from that authored expectation. Cells the sandbox cannot execute are labelled as standard Python instead of pretending to run.

The figures

diff --git a/src/templates/example.html b/src/templates/example.html index 87d7898..7a7973e 100644 --- a/src/templates/example.html +++ b/src/templates/example.html @@ -1,8 +1,8 @@
-
+

__SECTION__

-

__TITLE__

+

__TITLE__

__SUMMARY__

__WALKTHROUGH__
@@ -19,10 +19,10 @@

Example code

__TURNSTILE_CHALLENGE__
- +
-

__OUTPUT_HEADING__

__SHOWN_OUTPUT__

__EXECUTION_TIME__

+

__OUTPUT_HEADING__

__SHOWN_OUTPUT__

__EXECUTION_TIME__

diff --git a/src/templates/home.html b/src/templates/home.html index b0d1cd8..a6cc1c6 100644 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -1,10 +1,11 @@ -
-

Python By Example

+
+

Python By Example

Learn Python with small, editable examples backed by the official Python __PYTHON_VERSION__ docs. Run each snippet in an isolated Dynamic Python Worker using the newest Python version currently supported by Cloudflare Workers/Pyodide.

__CARDS__ diff --git a/src/templates/layout.html b/src/templates/layout.html index d9abf85..4512ed2 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -5,11 +5,12 @@ __PAGE_TITLE__ - + __ROBOTS_META__ + __CANONICAL_TAG__ - + __OG_URL_TAG__ __SOCIAL_IMAGE_TAGS__ diff --git a/src/worker_asgi_bridge.py b/src/worker_asgi_bridge.py index 8d4a704..faf01ec 100644 --- a/src/worker_asgi_bridge.py +++ b/src/worker_asgi_bridge.py @@ -212,13 +212,13 @@ async def send(got): with acquire_js_buffer(body) as jsbytes: await writer.write(jsbytes.slice()) else: - # Complete body in a single chunk - px = create_proxy(body) - buf = px.getBuffer() - px.destroy() - resp = Response.new( - buf.data, headers=Object.fromEntries(headers), status=status - ) + # Complete body in a single chunk. Response construction may + # retain the source view, so copy it before releasing the + # Pyodide buffer handle just as the streaming path does. + with acquire_js_buffer(body) as jsbytes: + resp = Response.new( + jsbytes.slice(), headers=Object.fromEntries(headers), status=status + ) result.set_result(resp) finished_response.set() @@ -270,7 +270,7 @@ def onopen(evt): onopen(1) def onclose(evt): - msg = {"type": "websocket.close", "code": evt.code, "reason": evt.reason} + msg = {"type": "websocket.disconnect", "code": evt.code, "reason": evt.reason} queue.put_nowait(msg) def onmessage(evt): @@ -287,12 +287,13 @@ async def ws_send(got): s = got.get("text", None) if b: with acquire_js_buffer(b) as jsbytes: - # Unlike the `Response` constructor, server.send seems to - # eagerly copy the source buffer + # Unlike the `Response` constructor, server.send eagerly + # copies the source buffer. server.send(jsbytes) if s: server.send(s) - + elif got["type"] == "websocket.close": + server.close(got.get("code", 1000), got.get("reason", "")) else: logger.warning(" == Not implemented %s", got["type"]) diff --git a/tests/test_app.py b/tests/test_app.py index 069a3d7..fa0db64 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -376,7 +376,7 @@ def test_cf_workers_design_system_and_playground_lessons(self): self.assertNotIn('border-style: dashed', html) self.assertIn('class="playground-toolbar"', html) self.assertIn('type="submit">Run', html) - self.assertIn('type="button" data-reset', html) + self.assertIn('type="reset" data-reset', html) self.assertNotIn('data-copy', html) self.assertNotIn('data-share', html) self.assertIn('output-panel', html) @@ -430,6 +430,15 @@ def test_generated_drift_is_blocked_before_commit_and_merge(self): self.assertIn("make check-generated", hook) self.assertIn(".githooks/pre-commit", installer) self.assertIn("make verify", verify_workflow) + self.assertIn("uv sync --locked --all-groups", verify_workflow) + self.assertIn("npm ci --ignore-scripts", verify_workflow) + preview_workflow = (ROOT / ".github" / "workflows" / "preview.yml").read_text() + self.assertIn("uv sync --locked --all-groups", preview_workflow) + self.assertIn("npm exec -- wrangler whoami", preview_workflow) + package = json.loads((ROOT / "package.json").read_text()) + lock = json.loads((ROOT / "package-lock.json").read_text()) + self.assertEqual(package["devDependencies"]["wrangler"], "4.110.0") + self.assertEqual(lock["packages"][""]["devDependencies"]["wrangler"], "4.110.0") self.assertFalse((ROOT / ".github" / "workflows" / "regenerate-generated-files.yml").exists()) def test_conditionals_are_substantive_not_bare_minimum(self): @@ -567,18 +576,12 @@ def test_dark_mode_keeps_marginalia_figures_on_light_paper(self): self.assertIn(".cell-banner figure svg", dark_block) self.assertIn(".journey-section-figure svg", dark_block) - def test_shiki_highlights_with_dual_light_dark_themes(self): - js = (ROOT / "public" / "syntax-highlight.js").read_text() - self.assertIn("github-light", js) - self.assertIn("github-dark", js) - self.assertIn("themes:", js) - css = (ROOT / "public" / "site.css").read_text() - self.assertIn("--shiki-dark", css) - - def test_editor_picks_a_dark_highlight_style_in_dark_mode(self): - js = (ROOT / "public" / "editor.js").read_text() - self.assertIn("prefers-color-scheme: dark", js) - self.assertIn("oneDarkHighlightStyle", js) + def test_optional_highlighters_retain_readable_server_fallbacks(self): + page = render_example_page(get_example("values")) + self.assertIn('
', page)
+        self.assertIn('aria-label="Editable Python example code"', page)
+        self.assertIn('"))
 
+    def test_404_pages_omit_canonical_and_og_url_metadata(self):
+        for response in (
+            route("https://example.test/no-such-page"),
+            route("https://example.test/examples/no-such-example"),
+            route("https://example.test/journeys/no-such-journey"),
+        ):
+            with self.subTest(body=response.body[:80]):
+                self.assertEqual(response.status, 404)
+                self.assertNotIn('rel="canonical"', response.body)
+                self.assertNotIn('property="og:url"', response.body)
+                self.assertIn('', response.body)
+
     def test_robots_txt_references_sitemap(self):
         robots = (ROOT / "public" / "robots.txt").read_text()
         self.assertIn("Sitemap: https://www.pythonbyexample.dev/sitemap.xml", robots)
@@ -999,15 +1014,6 @@ def test_share_button_copies_a_code_fragment_link(self):
         self.assertIn(".playground-toolbar", js)
         self.assertIn("aria-live", js)
 
-    def test_share_encoder_mirrors_the_day_one_decoder(self):
-        js = (ROOT / "public" / "runner.js").read_text()
-        self.assertIn("decodeURIComponent(escape(atob(hash.slice(6))))", js)
-        self.assertIn("btoa(unescape(encodeURIComponent(code)))", js)
-
-    def test_hash_decode_syncs_the_enhanced_editor(self):
-        js = (ROOT / "public" / "runner.js").read_text()
-        self.assertIn("setCode(decodeURIComponent(escape(atob(hash.slice(6)))))", js)
-
     def test_shared_link_recipients_see_a_status_notice(self):
         js = (ROOT / "public" / "runner.js").read_text()
         self.assertIn("This link included edited code. Press Run to execute it.", js)
diff --git a/tests/test_main_observability.py b/tests/test_main_observability.py
index 2c23cea..3b39a59 100644
--- a/tests/test_main_observability.py
+++ b/tests/test_main_observability.py
@@ -139,6 +139,75 @@ def import_main(self):
         return importlib.import_module("main")
 
 
+class _Headers(dict):
+    def set(self, name, value):
+        self[name] = value
+
+
+class MainDefaultFetchTests(MainModuleHarness):
+    def test_post_and_bridge_rejections_get_security_no_store_and_status_observability(self):
+        main = self.import_main()
+        emitted = []
+        event = {"cache": "bypass"}
+        response = SimpleNamespace(status=413, headers=_Headers())
+
+        async def asgi_fetch(*args, **kwargs):
+            return response
+
+        main.caches = None
+        main.asgi.fetch = asgi_fetch
+        main.observability.event_from_worker_request = lambda *args, **kwargs: event
+        main.observability.emit = lambda value, env=None: emitted.append(value.copy())
+        worker = main.Default()
+        worker.env = SimpleNamespace()
+        request = SimpleNamespace(
+            method="POST",
+            url="https://www.pythonbyexample.dev/examples/values",
+            js_object=SimpleNamespace(),
+        )
+
+        returned = asyncio.run(worker.fetch(request))
+
+        self.assertIs(returned, response)
+        self.assertEqual(response.headers["Cache-Control"], "no-store")
+        for name, value in main.SECURITY_HEADERS.items():
+            self.assertEqual(response.headers[name], value)
+        self.assertEqual(emitted[-1]["status_code"], 413)
+        self.assertEqual(emitted[-1]["outcome"], "client_error")
+
+    def test_dynamic_response_reader_cancels_stream_above_output_cap(self):
+        main = self.import_main()
+        cancelled = []
+
+        class Chunk:
+            def __init__(self, value):
+                self.value = value
+
+            def to_bytes(self):
+                return self.value
+
+        class Reader:
+            def __init__(self):
+                self.results = [
+                    SimpleNamespace(done=False, value=Chunk(b"x" * 40_000)),
+                    SimpleNamespace(done=False, value=Chunk(b"y" * 40_000)),
+                ]
+
+            async def read(self):
+                return self.results.pop(0)
+
+            async def cancel(self, reason):
+                cancelled.append(reason)
+
+        reader = Reader()
+        response = SimpleNamespace(body=SimpleNamespace(getReader=lambda: reader))
+        text, exceeded = asyncio.run(main._read_dynamic_response_text(response))
+
+        self.assertTrue(exceeded)
+        self.assertEqual(text, main.DYNAMIC_OUTPUT_LIMIT_MESSAGE)
+        self.assertEqual(len(cancelled), 1)
+
+
 class MainObservabilityTests(MainModuleHarness):
     def test_run_example_marks_dynamic_worker_http_500_as_worker_error(self):
         main = self.import_main()
diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py
index 650bb03..f83ab61 100644
--- a/tests/test_main_routes.py
+++ b/tests/test_main_routes.py
@@ -46,3 +46,6 @@ def test_unknown_paths_fall_back_to_html(self):
             main.not_found("no-such-page", self.request("https://example.test/no-such-page"))
         )
         self.assertEqual(response.status_code, 404)
+        self.assertNotIn('rel="canonical"', response.content)
+        self.assertNotIn('property="og:url"', response.content)
+        self.assertIn('', response.content)
diff --git a/tests/test_main_turnstile.py b/tests/test_main_turnstile.py
index ec8af1d..23cae9a 100644
--- a/tests/test_main_turnstile.py
+++ b/tests/test_main_turnstile.py
@@ -67,6 +67,8 @@ def make_request(*, cookie: str = "", headers: dict[str, str] | None = None, env
             "path": "/examples/values",
             "query_string": b"",
             "headers": raw_headers,
+            "scheme": "https",
+            "server": ("www.pythonbyexample.dev", 443),
             "env": env,
         }
     )
@@ -274,6 +276,18 @@ def test_off_mode_runs_without_challenge(self):
         self.assertEqual(self._ran_code, "print(42)")
         self.assertIn("ran-output", response.body.decode())
 
+    def test_dynamic_worker_output_rejection_propagates_413(self):
+        async def rejected(request, slug, code):
+            request.state.dynamic_worker_status = 413
+            return main.DYNAMIC_OUTPUT_LIMIT_MESSAGE
+
+        main._run_example = rejected
+        response = self._run(
+            post_request(b"code=print(42)", env=session_env(TURNSTILE_CHALLENGE_MODE="off"))
+        )
+        self.assertEqual(response.status_code, 413)
+        self.assertIn("output exceeded", response.body.decode())
+
     def test_invalid_raw_or_percent_encoded_utf8_is_rejected_before_execution(self):
         for body in (b"code=\xff", b"code=%FF"):
             with self.subTest(body=body):
@@ -340,11 +354,11 @@ async def failing(_request):
                 self.assertIn("verification failed", message)
                 self.assertEqual(outcome, "fail")
 
-    def test_successful_siteverify_requires_true_success_flag(self):
+    def test_successful_siteverify_requires_matching_hostname_and_action(self):
         class Response:
             status = 200
             async def text(self):
-                return '{"success": true}'
+                return '{"success": true, "hostname": "www.pythonbyexample.dev", "action": "run-example"}'
 
         main.js_fetch = lambda _request: _awaitable(Response())
         ok, message, outcome = asyncio.run(main._verify_turnstile(make_request(env=session_env()), "token"))
@@ -352,6 +366,26 @@ async def text(self):
         self.assertEqual(message, "")
         self.assertEqual(outcome, "pass")
 
+    def test_siteverify_rejects_wrong_or_missing_hostname_and_action(self):
+        payloads = [
+            '{"success": true}',
+            '{"success": true, "hostname": "other.example", "action": "run-example"}',
+            '{"success": true, "hostname": "www.pythonbyexample.dev", "action": "other-action"}',
+        ]
+        for payload in payloads:
+            with self.subTest(payload=payload):
+                class Response:
+                    status = 200
+
+                    async def text(self):
+                        return payload
+
+                main.js_fetch = lambda _request: _awaitable(Response())
+                ok, message, outcome = asyncio.run(main._verify_turnstile(make_request(env=session_env()), "token"))
+                self.assertFalse(ok)
+                self.assertIn("verification failed", message)
+                self.assertEqual(outcome, "fail")
+
 
 async def _awaitable(value):
     return value
@@ -403,6 +437,16 @@ def __init__(self, body, status=200, headers=None):
             self.assertEqual(huge_error.status, 413)
             self.assertEqual(huge_error.body, DYNAMIC_OUTPUT_LIMIT_MESSAGE)
             self.assertLessEqual(len(huge_error.body.encode()), MAX_DYNAMIC_OUTPUT_BYTES)
+            namespace = {}
+            exec(
+                build_dynamic_worker_code(
+                    f"import sys; sys.stdout.parts.append('x' * {MAX_DYNAMIC_OUTPUT_BYTES + 1})"
+                ),
+                namespace,
+            )
+            mutated_sink = asyncio.run(namespace["Default"]().fetch(None))
+            self.assertEqual(mutated_sink.status, 413)
+            self.assertEqual(mutated_sink.body, DYNAMIC_OUTPUT_LIMIT_MESSAGE)
         finally:
             if saved_workers is None:
                 sys.modules.pop("workers", None)
diff --git a/tests/test_social_cards.py b/tests/test_social_cards.py
index 7c1a9fc..2eabf28 100644
--- a/tests/test_social_cards.py
+++ b/tests/test_social_cards.py
@@ -1,8 +1,11 @@
 import json
 import tempfile
 import unittest
+from io import BytesIO
 from pathlib import Path
 
+from PIL import Image
+
 from scripts import build_social_cards as cards
 
 
@@ -23,8 +26,10 @@ def tearDown(self):
     def _write_current(self, sources):
         self.output.mkdir(exist_ok=True)
         cards.PROVENANCE_PATH.write_text(json.dumps(cards.provenance(sources)))
+        buffer = BytesIO()
+        Image.new('RGB', (1200, 630), '#fff').save(buffer, format='JPEG')
         for name in cards.provenance(sources)['inputs']:
-            (self.output / name).write_bytes(b'jpeg')
+            (self.output / name).write_bytes(buffer.getvalue())
 
     def test_current_provenance_and_exact_jpeg_set_pass(self):
         sources = {'home': 'home', 'values': 'values'}
@@ -40,6 +45,21 @@ def test_changed_input_missing_and_orphan_cards_fail(self):
         self.assertTrue(any('missing social card values.jpg' in failure for failure in failures))
         self.assertTrue(any('unexpected stale social card orphan.jpg' in failure for failure in failures))
 
+    def test_corrupt_and_wrong_size_cards_fail(self):
+        sources = {'home': 'home'}
+        self._write_current(sources)
+        valid_jpeg = (self.output / 'home.jpg').read_bytes()
+        (self.output / 'home.jpg').write_bytes(b'not-a-jpeg')
+        self.assertTrue(any('invalid JPEG' in failure for failure in cards.check_current(sources)))
+
+        (self.output / 'home.jpg').write_bytes(valid_jpeg[:-10])
+        self.assertTrue(any('invalid JPEG' in failure for failure in cards.check_current(sources)))
+
+        buffer = BytesIO()
+        Image.new('RGB', (1, 1), '#fff').save(buffer, format='JPEG')
+        (self.output / 'home.jpg').write_bytes(buffer.getvalue())
+        self.assertTrue(any('expected 1200x630' in failure for failure in cards.check_current(sources)))
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_worker_asgi_bridge_scope.py b/tests/test_worker_asgi_bridge_scope.py
index 26b5846..1bab308 100644
--- a/tests/test_worker_asgi_bridge_scope.py
+++ b/tests/test_worker_asgi_bridge_scope.py
@@ -40,36 +40,80 @@ def new(cls, value):
                     search=f"?{parsed.query}" if parsed.query else "",
                 )
 
-        # Minimal JS-runtime fakes used by process_request's non-streaming path.
+        # Minimal JS-runtime fakes used by HTTP and WebSocket bridge paths.
         class Response:
-            def __init__(self, body, headers, status):
+            def __init__(self, body, headers, status, web_socket=None):
                 self.body = body
                 self.headers = headers
                 self.status = status
+                self.webSocket = web_socket
 
             @classmethod
-            def new(cls, body, *, headers=None, status=200):
-                return cls(body, headers, status)
+            def new(cls, body, *, headers=None, status=200, webSocket=None):
+                return cls(body, headers, status, webSocket)
 
         class Object:
             @staticmethod
             def fromEntries(pairs):
                 return dict(pairs)
 
+        class WebSocketServer:
+            def __init__(self):
+                self.accepted = False
+                self.onopen = None
+                self.onclose = None
+                self.onmessage = None
+                self.sent = []
+                self.closed = None
+
+            def accept(self):
+                self.accepted = True
+
+            def send(self, value):
+                self.sent.append(value)
+
+            def close(self, code=1000, reason=""):
+                self.closed = (code, reason)
+
+        class WebSocketPair:
+            @classmethod
+            def new(cls):
+                client = SimpleNamespace(name="client")
+                server = WebSocketServer()
+                self.ws_server = server
+                return SimpleNamespace(object_values=lambda: (client, server))
+
         js.URL = URL
         js.Response = Response
         js.Object = Object
         js.TransformStream = object  # only referenced on the streaming path
+        js.WebSocketPair = WebSocketPair
         sys.modules["js"] = js
 
         ffi = types.ModuleType("pyodide.ffi")
+        self.released_buffers = 0
+        test_case = self
+
+        class _JSBytes:
+            def __init__(self, value):
+                self.value = value
+
+            def slice(self):
+                return self.value
+
+        class _Buffer:
+            def __init__(self, value):
+                self.data = _JSBytes(value)
+
+            def release(self):
+                test_case.released_buffers += 1
 
         class _Proxy:
             def __init__(self, obj):
                 self._obj = obj
 
             def getBuffer(self):
-                return SimpleNamespace(data=self._obj)
+                return _Buffer(self._obj)
 
             def destroy(self):
                 pass
@@ -146,6 +190,29 @@ async def app(scope, receive, send):
         self.assertEqual(response.body, b"echo:ping")
         self.assertEqual(response.headers.get("content-type"), "text/plain")
         self.assertEqual(response.headers.get("x-trace"), "abc")
+        self.assertEqual(self.released_buffers, 1)
+
+    def test_latin1_headers_round_trip_through_scope_and_response(self):
+        async def app(scope, receive, send):
+            self.assertIn((b"x-label", b"caf\xe9"), scope["headers"])
+            await send(
+                {
+                    "type": "http.response.start",
+                    "status": 200,
+                    "headers": [(b"x-label", b"caf\xe9")],
+                }
+            )
+            await send({"type": "http.response.body", "body": b"ok", "more_body": False})
+
+        req = self._fake_request(
+            url="https://x.dev/examples/values",
+            headers={"X-Label": "café"},
+            body_chunks=[],
+        )
+        response = asyncio.run(
+            self.bridge.process_request(app, req, SimpleNamespace(), None, state={})
+        )
+        self.assertEqual(response.headers["x-label"], "café")
 
     def test_process_request_propagates_app_exception(self):
         async def app(scope, receive, send):
@@ -202,6 +269,84 @@ class FakeRequest(self.Request):
         self.assertIs(scope["state"]["wide_event"], wide_event)
         self.assertEqual(scope["state"]["cache"], "bypass")
 
+    def test_websocket_close_callback_delivers_disconnect_event(self):
+        received = []
+
+        async def app(scope, receive, send):
+            self.assertEqual(scope["type"], "websocket")
+            received.append(await receive())
+            received.append(await receive())
+
+        req = self._fake_request(
+            method="GET",
+            url="https://x.dev/socket",
+            headers={},
+            body_chunks=[],
+        )
+
+        async def scenario():
+            response = await self.bridge.process_websocket(app, req)
+            await asyncio.sleep(0)
+            self.ws_server.onclose(SimpleNamespace(code=1001, reason="leaving"))
+            for _ in range(20):
+                if len(received) == 2:
+                    break
+                await asyncio.sleep(0)
+            return response
+
+        response = asyncio.run(scenario())
+        self.assertEqual(response.status, 101)
+        self.assertEqual(received[0], {"type": "websocket.connect"})
+        self.assertEqual(
+            received[1],
+            {"type": "websocket.disconnect", "code": 1001, "reason": "leaving"},
+        )
+
+    def test_websocket_application_close_closes_server_socket(self):
+        async def app(scope, receive, send):
+            await receive()
+            await send({"type": "websocket.close", "code": 1008, "reason": "policy"})
+
+        req = self._fake_request(
+            method="GET",
+            url="https://x.dev/socket",
+            headers={},
+            body_chunks=[],
+        )
+        response = asyncio.run(self.bridge.process_websocket(app, req))
+        self.assertEqual(response.status, 101)
+        self.assertEqual(self.ws_server.closed, (1008, "policy"))
+
+    def test_fetch_starts_lifespan_once_per_application(self):
+        calls = {"startup": 0, "request": 0}
+        app = object()
+        self.bridge._app_lifespans.clear()
+
+        async def start_application(_app):
+            self.assertIs(_app, app)
+            calls["startup"] += 1
+
+        async def process_request(_app, req, env, ctx, **kwargs):
+            self.assertIs(_app, app)
+            calls["request"] += 1
+            return SimpleNamespace(status=200)
+
+        self.bridge.start_application = start_application
+        self.bridge.process_request = process_request
+        req = self._fake_request(
+            method="GET",
+            url="https://x.dev/",
+            headers={},
+            body_chunks=[],
+        )
+
+        async def scenario():
+            await self.bridge.fetch(app, req, SimpleNamespace())
+            await self.bridge.fetch(app, req, SimpleNamespace())
+
+        asyncio.run(scenario())
+        self.assertEqual(calls, {"startup": 1, "request": 2})
+
     def test_bridge_has_pre_asgi_body_cap_hook(self):
         bridge_source = (ROOT / "src" / "worker_asgi_bridge.py").read_text()
         main_source = (ROOT / "src" / "main.py").read_text()
diff --git a/uv.lock b/uv.lock
index 5fccfdc..be8c99e 100644
--- a/uv.lock
+++ b/uv.lock
@@ -99,6 +99,39 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
 ]
 
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+    { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+    { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+    { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+    { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+    { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+    { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+    { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+    { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+    { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+    { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+]
+
 [[package]]
 name = "pydantic"
 version = "2.13.3"
@@ -209,6 +242,7 @@ dependencies = [
 
 [package.dev-dependencies]
 dev = [
+    { name = "pillow" },
     { name = "ruff" },
 ]
 workers = [
@@ -220,7 +254,10 @@ workers = [
 requires-dist = [{ name = "fastapi" }]
 
 [package.metadata.requires-dev]
-dev = [{ name = "ruff", specifier = ">=0.8" }]
+dev = [
+    { name = "pillow", specifier = ">=11.3,<12" },
+    { name = "ruff", specifier = ">=0.8" },
+]
 workers = [
     { name = "workers-py" },
     { name = "workers-runtime-sdk" },