From 5391d2172b02191f7848826799a02197a7da1c98 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 19 Aug 2026 10:07:06 +0000 Subject: [PATCH] feat(repo): publish from Cloudflare Pages, and generate the cache contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0001 §5 specified a GitHub Pages artifact fronted by Cloudflare for the immutable cache headers Pages cannot set itself. Both halves cannot hold: GitHub cannot renew a custom domain's certificate behind an orange cloud, and proxying is the only reason Cloudflare was in the path. Every $id this repository publishes is a dead URL today. The origin becomes Cloudflare Pages, and the cache policy moves into this repository as a generated _headers. site.ts already enumerates every path it writes and knows which are aliases and which are pinned, so there is no second source of truth. Pages merges every matching rule and comma-joins duplicate header names rather than letting the more specific win, so the rules must partition; assertNoOverlap holds that over the paths actually written. The origin also gains a human entry point — / and // were 404s — and drops CNAME and .nojekyll, neither of which ever did anything. Closes #49, closes #50. Records in ADR 0012 §5 that musher-dev/specs is archived rather than deleted as ADR 0001 §1 said; #51 stays open for the organisation ruleset that still names it, which no change here can make. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- .config/spelling/musher.txt | 3 + .github/workflows/pages.yml | 27 +- .github/workflows/release.yml | 6 +- CLAUDE.md | 6 +- GOVERNANCE.md | 11 +- README.md | 12 + docs/adr/0012-cloudflare-pages-publication.md | 226 +++++++++ taskfiles/build.Taskfile.yml | 20 +- tools/bun.lock | 181 +++++++ tools/package.json | 3 +- tools/src/site.test.ts | 230 ++++++++- tools/src/site.ts | 456 ++++++++++++++++-- tools/src/spec.ts | 8 +- 13 files changed, 1134 insertions(+), 55 deletions(-) create mode 100644 docs/adr/0012-cloudflare-pages-publication.md diff --git a/.config/spelling/musher.txt b/.config/spelling/musher.txt index 4ffe50d..dfec631 100644 --- a/.config/spelling/musher.txt +++ b/.config/spelling/musher.txt @@ -30,6 +30,7 @@ deserialisation # --- Names and acronyms ------------------------------------------------------ bluemonday Pydantic +Segoe SLSA # --- Hardware and format vocabulary the schemas name -------------------------- @@ -46,7 +47,9 @@ commonmark gpgsign metaschema nojekyll +nosniff pycache +quot subschema subschemas Taskfile diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 56d4fdf..0e64d49 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -25,13 +25,11 @@ jobs: concurrency: group: pages-deploy cancel-in-progress: false + # No `pages: write` or `id-token: write`: the origin is Cloudflare Pages, and + # the credential that reaches it is a repository secret rather than a + # workflow token. See docs/adr/0012. permissions: contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} steps: # Full history and every tag: pinned URLs are rebuilt from the tags, so a # shallow checkout here would unpublish every released version. @@ -65,14 +63,17 @@ jobs: - name: Verify published versions run: task check:published + # `_headers` is assembled here too, from the same enumeration that writes + # the tree. The cache contract is therefore reviewed in this repository + # rather than configured at an edge this repository cannot see. - name: Assemble publication tree run: task site:build - - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: site - - - id: deployment - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + # The token is scoped to the musher-schemas Pages project. `task + # site:deploy` refuses to run without both, so a missing secret fails the + # job instead of deploying anonymously. + - name: Deploy to Cloudflare Pages + run: task site:deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 830e2cf..0251e8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,6 +197,8 @@ jobs: if: needs.release-please.outputs.paths_released != '' && needs.release-please.outputs.paths_released != '[]' permissions: contents: read - pages: write - id-token: write + # A reusable workflow inherits no secrets by default. Without this the + # deploy would fail on exactly the run that matters most — the one that + # publishes a version at its own immutable URL for the first time. + secrets: inherit uses: ./.github/workflows/pages.yml diff --git a/CLAUDE.md b/CLAUDE.md index 7eeec19..cd5819e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,11 @@ implementation of what is defined here. pull request, not by hand. See docs/adr/0006. 7. **Exact-version URLs are rebuilt from tags, never from `main`.** `tools/src/site.ts` extracts each release from its own tag. Nothing in the - working tree may feed a path that has already been published. + working tree may feed a path that has already been published. The whole + published tree is generated by that one script — `_headers` and the index + pages included — so the cache policy of a path cannot drift from the path. + `_headers` rules must never overlap on a header name; the build enforces it. + See docs/adr/0012. 8. **Tool configuration lives in `.config//`, passed by path.** Never add a config to the repo root when the tool accepts a config flag, and never rely on default discovery — every caller names its config explicitly. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index e32e4f7..ce48ab3 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -128,7 +128,8 @@ release-please PR; merging that PR tags the release and triggers publication. SHA-256 checksums and a SLSA provenance attestation 4. The archive is attached to a GitHub Release 5. The schema is published to `https://schemas.musher.dev//…`, rebuilt - from the tag rather than from `main` + from the tag rather than from `main`, with the cache policy for its path + generated alongside it 6. `catalog.json` is regenerated for editor discovery Released versions are **immutable**. Tag deletion and update are blocked by @@ -181,6 +182,14 @@ the AGPL, and no artifact this repository publishes derives from it. A contributor without it sees the checks report themselves as skipped rather than passed. +One more is worth naming, for a different reason. +[`wrangler`](https://github.com/cloudflare/workers-sdk) uploads the publication +tree to Cloudflare Pages, which makes it the only dependency here that is handed +a credential. It is pinned to an exact version in `tools/package.json` and the +lockfile rather than fetched at deploy time, so the code that receives the token +changes only through a reviewed diff under CODEOWNERS. Nothing else in `tools/` +holds a secret, and nothing published derives from wrangler either. + ## Security See [SECURITY.md](.github/SECURITY.md). diff --git a/README.md b/README.md index 49145b5..35a63b7 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,18 @@ checksum of every version this repository has ever released. See Once a family is tagged its alias serves that family's newest release; before its first tag the alias serves what is committed on `main`. +### How they are served + +`https://schemas.musher.dev/` is browsable: it lists every family, and each +family's page lists every version it has published with that version's checksum. + +Every schema is served as `application/schema+json` with CORS open, so a +browser-based validator can fetch it. An exact-version path is +`immutable` for a year; an alias revalidates within five minutes of a release. +Those headers are generated from the same enumeration that assembles the tree, +so a published path cannot be served a cache policy nobody wrote down. See +[ADR 0012](docs/adr/0012-cloudflare-pages-publication.md). + ### Offline Every published schema is a self-contained compound document — all `$ref`s diff --git a/docs/adr/0012-cloudflare-pages-publication.md b/docs/adr/0012-cloudflare-pages-publication.md new file mode 100644 index 0000000..231bacc --- /dev/null +++ b/docs/adr/0012-cloudflare-pages-publication.md @@ -0,0 +1,226 @@ +# ADR 0012: Publication moves to Cloudflare Pages, and the cache contract moves into this repository + +- **Status:** Accepted +- **Date:** 2026-08-19 +- **Supersedes:** [ADR 0001](0001-canonical-repository-architecture.md) §5 +- **Refines:** [ADR 0001](0001-canonical-repository-architecture.md) §1 +- **Closes:** [ADR 0006](0006-publication-from-tags.md) follow-up 1 + +## Context + +Every `$id` this repository publishes is a dead URL. `schemas.musher.dev` has never +resolved, and `gh api repos/musher-dev/spec/pages` reports `cname: null`. +`musher-dev/infra` is standing the hostname up now, and doing so established that +[ADR 0001](0001-canonical-repository-architecture.md) §5 cannot hold as written. + +It specifies "a GitHub Pages artifact fronted by Cloudflare, which supplies the +immutable cache headers Pages cannot set itself". Both halves cannot be true at once: + +1. **GitHub cannot renew a custom domain's TLS certificate behind an orange cloud.** + Issuance and renewal resolve the hostname and require GitHub's own addresses in the + answer; proxied, they see Cloudflare anycast instead. + [`github/pages-health-check#153`](https://github.com/github/pages-health-check/issues/153) + has been open since 2023, and the advice in-thread from a former GitHub Pages + engineer is to turn proxying off. Left on, it fails at roughly ninety days as a hard + `525` on every published `$id`. But proxying is precisely what the immutable cache + headers require — it is the only reason Cloudflare was in the path. +2. **The workarounds are plan-gated.** Origin Rules' Host-header, SNI and DNS-record + overrides are Enterprise-only, and the `matches` operator needed to separate + `/v1.2.3/` from `/v1/` is Business and above. The `musher.dev` zone is Free. + +Cloudflare Pages dissolves both. Cloudflare terminates TLS for the hostname natively, +so no third-party certificate exists to expire, and `_headers` sets the cache policy at +the origin. [ADR 0006](0006-publication-from-tags.md) anticipated the move — *"Revisit +when publication outgrows Pages"* — and its reasoning against R2 does not transfer: the +artifact is still built in-repo by the same gated pipeline, and only the upload target +changes. + +This also discharges ADR 0006 follow-up 1, which asked for the versioned path's cache +rule to be recorded here. It is better than recorded: it is generated, from the same +enumeration that writes the tree. ADR 0001 follow-up 1 assigned both the DNS record and +the cache rule to `musher-dev/infra`; the DNS half stays there, and the cache half ends +here. + +## Decision + +### 1. The origin is Cloudflare Pages + +`.github/workflows/pages.yml` uploads with `wrangler pages deploy` in place of +`actions/upload-pages-artifact` and `actions/deploy-pages`. The `environment: +github-pages` block and the `pages: write` / `id-token: write` grants go with them. + +Everything the publication guarantee rests on is unchanged: `fetch-depth: 0`, the +`check:drift` and `check:published` gates ahead of the upload, and the job-level +`pages-deploy` concurrency group that never cancels in flight. + +### 2. `_headers` is generated, and no two of its rules overlap + +Cloudflare Pages applies **every** rule whose pattern matches, and joins duplicate +header names with a comma. A more specific rule does not override a general one. So the +obvious shape — one broad rule for pinned paths plus per-alias overrides — emits + +``` +Cache-Control: public, max-age=31536000, immutable, public, max-age=300, must-revalidate +``` + +and the alias is cached for a year. The rules must therefore partition: for any served +path, at most one matching rule may set a given header name. + +`tools/src/site.ts` already enumerates every path it writes and already knows which are +aliases and which are pinned, so it emits `_headers` with no second source of truth to +keep in step. Three rules key on a path *shape* and set headers nothing else sets; +`Cache-Control` is set by one exact rule per alias and inventory, and one directory rule +per released version. + +```text +/* + Access-Control-Allow-Origin: * + X-Content-Type-Options: nosniff + +/*.schema.json + Content-Type: application/schema+json; charset=utf-8 + +/component/v1/component.schema.json + Cache-Control: public, max-age=300, must-revalidate + +/component/v1.0.0/* + Cache-Control: public, max-age=31536000, immutable +``` + +`application/schema+json` with `public, max-age=31536000, immutable` is what +`json-schema.org` serves for the same kind of document. + +Three properties are worth stating because each is easy to lose later. + +**The partition is enforced, not documented.** `assertNoOverlap` runs over the paths +actually written on every build. A new artifact cannot quietly acquire a second opinion +about how long it may be cached, and the check cannot fall behind the tree because it +reads the tree. + +**A release costs one rule, not two.** The directory rule covers a version's schema and +its `.sha256` sidecar together, and the sidecar is exactly as immutable as the bytes it +attests. `_headers` caps at one hundred rules; the build fails at ninety, which leaves +room for roughly eighty published versions across the three families. Failing early +matters because a file over the ceiling is rejected wholesale — the failure mode is not +a truncated policy but no policy at all. + +**Silence is safe.** Pages serves any uncontested asset as `public, max-age=0, +must-revalidate`. A path this file forgets therefore revalidates rather than pinning +stale bytes, which is why the pages in §4 take no rule at all. + +Two of the headers above restate Pages defaults. Cloudflare already sends +`Access-Control-Allow-Origin: *` and `X-Content-Type-Options: nosniff`, and `_headers` +overrides a default rather than appending to it. They are pinned anyway: README +instructs editors and browser-based validators to fetch these URLs cross-origin, so CORS +is a guarantee this repository makes, and a guarantee resting on a vendor default is one +that can be withdrawn without a commit here. + +### 3. The published tree carries no GitHub Pages artifacts + +`CNAME` and `.nojekyll` are no longer written. + +`CNAME` never did anything. GitHub's documentation is explicit that for an +Actions-published Pages site "no `CNAME` file is created, and any existing `CNAME` file +is ignored and is not required" — which is why `cname` reads `null` today despite +successful deploys. On Cloudflare Pages it would simply be served as a static file at +`https://schemas.musher.dev/CNAME`. `.nojekyll` guards against a Jekyll build that +Cloudflare Pages does not run. + +### 4. The origin has a human entry point + +`site.ts` emits `index.html` at the root and under each family, and a `404.html`. + +The root page names each family, its alias URL, its latest version, its `versions.json`, +and its prose. A family page lists every published version with its tag and checksum, +and says plainly when a family has released nothing and its alias is tracking `main` — +which is the state of all three families today. + +Generated rather than committed, for the reason `docs/traceability.md` is generated: a +page someone has to remember to update is a page that is wrong. Each link to a `spec.md` +resolves at the ref the reader is actually looking at — a released alias points at its +tag, an unreleased one at `main` — so the page cannot quietly offer prose that does not +match the schema beside it. + +Deliberately thin. This is a registry, not a documentation site; the normative prose +stays in each family's `spec.md`, and nothing here is normative. + +### 5. `musher-dev/specs` is archived, not deleted + +[ADR 0001](0001-canonical-repository-architecture.md) §1 said the superseded repository +"is superseded and will be deleted". It has been archived instead, and archived is the +better end state: deletion would `404` every inbound reference to it, including ADR +0001's own account of why this repository exists. An archived repository is read-only, +carries GitHub's own banner saying so, and keeps the history that explains the +supersession legible. + +One consequence is live rather than historical. That repository's README advertises +`schemas.musher.dev` URLs that were never served, and that hostname now resolves to +*this* repository's content — so it describes a contract it does not define. Its +retirement is finished by dropping `specs` from the organisation's `pr-workflow` +ruleset, which no change here can do. + +### 6. The deploy credential + +A push to `main` in a public repository now reaches a Cloudflare credential. That is the +genuine cost of this decision and is stated rather than mitigated away. + +What bounds it: the token is scoped to the one Pages project; `task site:deploy` refuses +to run unless both secrets are present, so a missing credential fails the job rather +than deploying anonymously; `check:drift` and `check:published` still run *before* the +credential is used, so an unreviewed bundle or a moved release stops the job first; and +wrangler is pinned in `tools/package.json` and `tools/bun.lock` rather than resolved at +deploy time, so the code that receives the token changes only through a reviewed +lockfile diff under CODEOWNERS. + +## Alternatives considered + +**Keep GitHub Pages and turn proxying off.** This is the upstream advice, and it fixes +certificate renewal. It also removes the only thing Cloudflare was doing — Pages cannot +set `Cache-Control`, so every pinned URL loses the immutability README promises it. +Solving the certificate by discarding the guarantee is not solving it. + +**A Cloudflare Worker in front of Pages.** Expressive enough to compute cache policy +from the path shape, and not plan-gated. But it is a second deployable that has to stay +in step with `site.ts`'s idea of which paths are pinned, and a policy expressed in code +running elsewhere is exactly the arrangement ADR 0006 follow-up 1 complained about. +`_headers` makes the same guarantee with nothing at runtime. + +**Write-once object storage (R2).** Still the correct end state at scale, and still +premature for the same reasons [ADR 0006](0006-publication-from-tags.md) gave. This +change does not move away from it; the tree is assembled identically either way. + +## Consequences + +**Positive** + +- Every published `$id` becomes a URL that resolves, with a certificate that renews. +- The caching half of the immutability promise is reviewable in a pull request, beside + the bytes it applies to, and is verified by the test suite rather than by inspection + of another repository's Terraform. +- `/` and `//` stop being `404`s. +- The rule budget makes the publication model's growth visible: the file that would + break is the file that fails the build. + +**Negative** + +- A Cloudflare credential is reachable from `main`, per §6. +- `wrangler` is a large dependency tree for one command, and it is the only tool here + whose job is to hold a secret. +- `application/schema+json` prompts a download in a browser where `application/json` + renders inline. That is the correct media type and what the canonical registry serves; + the index pages in §4 are what a human following a URL upward now lands on instead. +- The published origin can no longer be reproduced by reading `pages.yml` alone — the + Pages project and its custom domain live in `musher-dev/infra`. + +## Follow-ups + +1. Emit `Deprecation` and `Sunset` for a deprecated release. GOVERNANCE.md → + Deprecation and retirement promises those headers; nothing emits them, the ledger + has no field recording that a version is deprecated, and no version is. `_headers` + is now where they would go, and the ledger is where the fact would have to live. +2. Drop `specs` from the organisation ruleset that still names it, completing §5. No + infrastructure-as-code layer can create or destroy a repository, so this is + necessarily manual. +3. Confirm the served headers against the acceptance `curl` once `musher-dev/infra` + applies the Cloudflare token. Pinned-path immutability cannot be observed until a + family is tagged; until then the fixture suite is the only place it is exercised. diff --git a/taskfiles/build.Taskfile.yml b/taskfiles/build.Taskfile.yml index 8672403..da67464 100644 --- a/taskfiles/build.Taskfile.yml +++ b/taskfiles/build.Taskfile.yml @@ -1,4 +1,5 @@ -# Build and reporting tasks — everything that regenerates a committed artifact. +# Build and reporting tasks — everything that regenerates a generated artifact, +# plus the one step that ships it. # # Included flattened, so these keep their bare names (`task bundle`, not # `task build:bundle`): CI, the hooks and CONTRIBUTING all call them by name. @@ -39,3 +40,20 @@ tasks: dir: '{{.ROOT_DIR}}/{{.TOOLS_DIR}}' cmds: - bun run src/site.ts + + site:deploy: + desc: Deploy the assembled publication tree to Cloudflare Pages + # Run from the repo root, not tools/: the tree being uploaded is at the + # root, and wrangler resolves --project-name's asset directory from cwd. + # + # The lockfile-pinned binary rather than `bunx wrangler`. This is the one + # tool here that holds a credential, so the version that receives it is + # decided by a reviewed lockfile change, not by whatever `latest` resolves + # to at deploy time. See GOVERNANCE.md -> Tooling dependencies. + dir: '{{.ROOT_DIR}}' + requires: + vars: [CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID] + cmds: + - >- + {{.TOOLS_DIR}}/node_modules/.bin/wrangler pages deploy {{.SITE_DIR}} + --project-name=musher-schemas --branch=main diff --git a/tools/bun.lock b/tools/bun.lock index 210402f..6e76990 100644 --- a/tools/bun.lock +++ b/tools/bun.lock @@ -17,6 +17,7 @@ "cspell": "9", "markdownlint-cli2": "0.22.1", "typescript": "7.0.2", + "wrangler": "4.124.0", }, }, }, @@ -39,6 +40,20 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260815.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260815.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260815.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260815.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260815.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw=="], + "@cspell/cspell-bundled-dicts": ["@cspell/cspell-bundled-dicts@9.8.0", "", { "dependencies": { "@cspell/dict-ada": "^4.1.1", "@cspell/dict-al": "^1.1.1", "@cspell/dict-aws": "^4.0.17", "@cspell/dict-bash": "^4.2.2", "@cspell/dict-companies": "^3.2.11", "@cspell/dict-cpp": "^7.0.2", "@cspell/dict-cryptocurrencies": "^5.0.5", "@cspell/dict-csharp": "^4.0.8", "@cspell/dict-css": "^4.1.1", "@cspell/dict-dart": "^2.3.2", "@cspell/dict-data-science": "^2.0.13", "@cspell/dict-django": "^4.1.6", "@cspell/dict-docker": "^1.1.17", "@cspell/dict-dotnet": "^5.0.13", "@cspell/dict-elixir": "^4.0.8", "@cspell/dict-en-common-misspellings": "^2.1.12", "@cspell/dict-en-gb-mit": "^3.1.22", "@cspell/dict-en_us": "^4.4.33", "@cspell/dict-filetypes": "^3.0.18", "@cspell/dict-flutter": "^1.1.1", "@cspell/dict-fonts": "^4.0.6", "@cspell/dict-fsharp": "^1.1.1", "@cspell/dict-fullstack": "^3.2.9", "@cspell/dict-gaming-terms": "^1.1.2", "@cspell/dict-git": "^3.1.0", "@cspell/dict-golang": "^6.0.26", "@cspell/dict-google": "^1.0.9", "@cspell/dict-haskell": "^4.0.6", "@cspell/dict-html": "^4.0.15", "@cspell/dict-html-symbol-entities": "^4.0.5", "@cspell/dict-java": "^5.0.12", "@cspell/dict-julia": "^1.1.1", "@cspell/dict-k8s": "^1.0.12", "@cspell/dict-kotlin": "^1.1.1", "@cspell/dict-latex": "^5.1.0", "@cspell/dict-lorem-ipsum": "^4.0.5", "@cspell/dict-lua": "^4.0.8", "@cspell/dict-makefile": "^1.0.5", "@cspell/dict-markdown": "^2.0.16", "@cspell/dict-monkeyc": "^1.0.12", "@cspell/dict-node": "^5.0.9", "@cspell/dict-npm": "^5.2.38", "@cspell/dict-php": "^4.1.1", "@cspell/dict-powershell": "^5.0.15", "@cspell/dict-public-licenses": "^2.0.16", "@cspell/dict-python": "^4.2.26", "@cspell/dict-r": "^2.1.1", "@cspell/dict-ruby": "^5.1.1", "@cspell/dict-rust": "^4.1.2", "@cspell/dict-scala": "^5.0.9", "@cspell/dict-shell": "^1.1.2", "@cspell/dict-software-terms": "^5.2.2", "@cspell/dict-sql": "^2.2.1", "@cspell/dict-svelte": "^1.0.7", "@cspell/dict-swift": "^2.0.6", "@cspell/dict-terraform": "^1.1.3", "@cspell/dict-typescript": "^3.2.3", "@cspell/dict-vue": "^3.0.5", "@cspell/dict-zig": "^1.0.0" } }, "sha512-MpXFpVyBPfJQ1YuVotljqUaGf6lWuf+fuWBBgs0PHFYTSjRPWuIxviAaCDnup/CJLLH60xQL4IlcQe4TOjzljw=="], "@cspell/cspell-json-reporter": ["@cspell/cspell-json-reporter@9.8.0", "", { "dependencies": { "@cspell/cspell-types": "9.8.0" } }, "sha512-nqUaSo9T7l8KrE22gc7ZIs+zvP7ak1i7JqGdRs8sGvh2Ijqj43qYQLePgb1b/vm8a1bavnc51m+vf05hpd3g3Q=="], @@ -183,16 +198,142 @@ "@cspell/url": ["@cspell/url@9.8.0", "", {}, "sha512-LY1lFiZLTQF/ma1ilfKmRmFmEOw0RfYhyl0UMhY7/d93b+kiDMhxP/9Qir4+5LyiRncaE3++ZcWno9Hya+ssRg=="], + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@sourcemeta/jsonschema": ["@sourcemeta/jsonschema@16.7.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "jsonschema": "npm/cli.js" } }, "sha512-r+vFn/wfOvDO04k44WBAPB6RwiT09yca2xH3LbcKAQ0RdoudeTli4dzOcNIWdUytqWPw9nZMBd4Y83YEU8dyzA=="], + "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/commonmark": ["@types/commonmark@0.27.10", "", {}, "sha512-iEZobUnvlM+UX5fXWCmC4eQXwCs01Z8Xa1W0VjiWUF/XsNy4BHtskqJ9MyLZVMHbA0ezhyonCDqz3hMvsCm6Hg=="], @@ -255,6 +396,8 @@ "array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -279,6 +422,8 @@ "commonmark": ["commonmark@0.31.2", "", { "dependencies": { "entities": "~3.0.1", "mdurl": "~1.0.1", "minimist": "~1.2.8" }, "bin": { "commonmark": "bin/commonmark" } }, "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg=="], + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "cspell": ["cspell@9.8.0", "", { "dependencies": { "@cspell/cspell-json-reporter": "9.8.0", "@cspell/cspell-performance-monitor": "9.8.0", "@cspell/cspell-pipe": "9.8.0", "@cspell/cspell-types": "9.8.0", "@cspell/cspell-worker": "9.8.0", "@cspell/dynamic-import": "9.8.0", "@cspell/url": "9.8.0", "ansi-regex": "^6.2.2", "chalk": "^5.6.2", "chalk-template": "^1.1.2", "commander": "^14.0.3", "cspell-config-lib": "9.8.0", "cspell-dictionary": "9.8.0", "cspell-gitignore": "9.8.0", "cspell-glob": "9.8.0", "cspell-io": "9.8.0", "cspell-lib": "9.8.0", "fast-json-stable-stringify": "^2.1.0", "flatted": "^3.4.2", "semver": "^7.7.4", "tinyglobby": "^0.2.15" }, "bin": { "cspell": "bin.mjs", "cspell-esm": "bin.mjs" } }, "sha512-qL0VErMSn8BDxaPxcV+9uenffgjPS+5Jfz+m4rCsvYjzLwr7AaaJBWWSV2UiAe/4cturae8n8qzxiGnbbazkRw=="], "cspell-config-lib": ["cspell-config-lib@9.8.0", "", { "dependencies": { "@cspell/cspell-types": "9.8.0", "comment-json": "^4.6.2", "smol-toml": "^1.6.1", "yaml": "^2.8.3" } }, "sha512-gMJBAgYPvvO+uDFLUcGWaTu6/e+r8mm4GD4rQfWa/yV4F9fj+yOYLIMZqLWRvT1moHZX1FxyVvUbJcmZ1gfebg=="], @@ -303,12 +448,18 @@ "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="], "env-paths": ["env-paths@4.0.0", "", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.28.1", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -329,6 +480,8 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gensequence": ["gensequence@8.0.8", "", {}, "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], @@ -375,6 +528,8 @@ "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], @@ -441,6 +596,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "miniflare": ["miniflare@5.20260815.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260815.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -449,6 +606,10 @@ "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], @@ -465,6 +626,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], @@ -473,26 +636,44 @@ "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unicorn-magic": ["unicorn-magic@0.4.0", "", {}, "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw=="], "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "workerd": ["workerd@1.20260815.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260815.1", "@cloudflare/workerd-darwin-arm64": "1.20260815.1", "@cloudflare/workerd-linux-64": "1.20260815.1", "@cloudflare/workerd-linux-arm64": "1.20260815.1", "@cloudflare/workerd-windows-64": "1.20260815.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg=="], + + "wrangler": ["wrangler@4.124.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": "5.20260815.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260815.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260815.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "youch": ["youch@4.1.0-beta.10", "", { "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" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "cspell-config-lib/smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], "import-fresh/parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], diff --git a/tools/package.json b/tools/package.json index 602cdc0..fd11876 100644 --- a/tools/package.json +++ b/tools/package.json @@ -31,6 +31,7 @@ "@types/commonmark": "0.27.10", "cspell": "9", "markdownlint-cli2": "0.22.1", - "typescript": "7.0.2" + "typescript": "7.0.2", + "wrangler": "4.124.0" } } diff --git a/tools/src/site.test.ts b/tools/src/site.test.ts index f94a327..f3ca147 100644 --- a/tools/src/site.test.ts +++ b/tools/src/site.test.ts @@ -8,10 +8,10 @@ * two tests here are the ones that catch each of those. */ import { afterEach, describe, expect, test } from 'bun:test' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' import { record } from './ledger.ts' -import { assembleSite } from './site.ts' +import { assembleSite, type HeaderRule, renderHeaders } from './site.ts' import { FixtureRepo } from './testing/fixture.ts' let repo: FixtureRepo | null = null @@ -39,6 +39,60 @@ function readSite(fx: FixtureRepo, ...parts: string[]): string { return readFileSync(join(fx.root, 'site', ...parts), 'utf8') } +/** Every file in a site tree, as the `/`-prefixed paths a request would name. */ +function servedPaths(root: string): string[] { + const found: string[] = [] + const walk = (dir: string): void => { + for (const entry of readdirSync(dir).sort()) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) walk(path) + else found.push(`/${relative(root, path).split(/[\\/]/).join('/')}`) + } + } + walk(root) + return found.filter((path) => path !== '/_headers') +} + +/** Read `_headers` back into the rules it declares. */ +function parseHeaders(text: string): HeaderRule[] { + const rules: HeaderRule[] = [] + for (const line of text.split('\n')) { + if (line.trim() === '') continue + if (line.startsWith(' ')) { + const rule = rules[rules.length - 1] + if (rule === undefined) throw new Error(`header before any source: ${line}`) + ;(rule.headers as string[]).push(line.trim()) + continue + } + rules.push({ source: line, headers: [] }) + } + return rules +} + +/** + * Cloudflare's source matching, written out independently of the generator's + * own copy: a test that reuses the implementation under test would agree with + * it about a pattern they both got wrong. + */ +function matches(source: string, path: string): boolean { + if (!source.includes('*')) return source === path + const [head = '', tail = ''] = source.split('*') + return path.length >= head.length + tail.length && path.startsWith(head) && path.endsWith(tail) +} + +/** The headers a request for `path` would actually receive, per rule name. */ +function resolve(rules: readonly HeaderRule[], path: string): Map { + const resolved = new Map() + for (const rule of rules) { + if (!matches(rule.source, path)) continue + for (const header of rule.headers) { + const [name = '', ...rest] = header.split(':') + resolved.set(name, [...(resolved.get(name) ?? []), rest.join(':').trim()]) + } + } + return resolved +} + describe('assembleSite', () => { test('a pinned path does not move when main moves', () => { const fx = fixture() @@ -143,9 +197,15 @@ describe('assembleSite', () => { assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site-a') }) assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site-b') }) - const a = readFileSync(join(fx.root, 'site-a', 'component', 'v1.0.0', 'component.schema.json')) - const b = readFileSync(join(fx.root, 'site-b', 'component', 'v1.0.0', 'component.schema.json')) - expect(a.equals(b)).toBe(true) + // The whole tree, not one file: `_headers` and the pages are assembled from + // maps and sorts, which is exactly where a nondeterministic order hides. + const a = servedPaths(join(fx.root, 'site-a')) + expect(a).toEqual(servedPaths(join(fx.root, 'site-b'))) + for (const path of [...a, '/_headers']) { + const left = readFileSync(join(fx.root, 'site-a', path)) + const right = readFileSync(join(fx.root, 'site-b', path)) + expect(left.equals(right)).toBe(true) + } }) test('a release published under an older layout still resolves', () => { @@ -179,4 +239,162 @@ describe('assembleSite', () => { 'https://schemas.musher.dev/component/v1.0.0/component.schema.json', ) }) + + // --------------------------------------------------------------------------- + // The cache contract. Cloudflare Pages merges every matching rule and + // comma-joins duplicate header names, so `_headers` is only correct if no two + // rules that match the same path set the same header. + // --------------------------------------------------------------------------- + + test('no published path draws the same header from two rules', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + release(fx, 'component', 'v1', '1.1.0', fx.bundleDoc('component', 'v1', { minProperties: 1 })) + fx.writeBundle('listing', 'v1', fx.bundleDoc('listing', 'v1')) + fx.commit('feat(listing): an untagged family') + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const rules = parseHeaders(readSite(fx, '_headers')) + + for (const path of servedPaths(join(fx.root, 'site'))) { + for (const [name, values] of resolve(rules, path)) { + expect(`${path} ${name}: ${values.length}`).toBe(`${path} ${name}: 1`) + } + } + }) + + test('a pinned path is immutable and its alias is not', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const rules = parseHeaders(readSite(fx, '_headers')) + + expect(resolve(rules, '/component/v1.0.0/component.schema.json').get('Cache-Control')).toEqual([ + 'public, max-age=31536000, immutable', + ]) + expect(resolve(rules, '/component/v1/component.schema.json').get('Cache-Control')).toEqual([ + 'public, max-age=300, must-revalidate', + ]) + }) + + test('a checksum sidecar inherits its release immutability and its own type', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const sidecar = resolve( + parseHeaders(readSite(fx, '_headers')), + '/component/v1.0.0/component.schema.json.sha256', + ) + + expect(sidecar.get('Cache-Control')).toEqual(['public, max-age=31536000, immutable']) + expect(sidecar.get('Content-Type')).toEqual(['text/plain; charset=utf-8']) + }) + + test('every schema is served cross-origin as application/schema+json', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const rules = parseHeaders(readSite(fx, '_headers')) + + // README tells editors and browser-based validators to fetch these URLs. + for (const path of servedPaths(join(fx.root, 'site'))) { + expect(resolve(rules, path).get('Access-Control-Allow-Origin')).toEqual(['*']) + } + for (const path of [ + '/component/v1/component.schema.json', + '/component/v1.0.0/component.schema.json', + ]) { + expect(resolve(rules, path).get('Content-Type')).toEqual([ + 'application/schema+json; charset=utf-8', + ]) + } + }) + + test('the rule budget fails the build before Cloudflare rejects the file', () => { + const rules: HeaderRule[] = Array.from({ length: 91 }, (_, index) => ({ + source: `/component/v1.0.${index}/*`, + headers: ['Cache-Control: public, max-age=31536000, immutable'], + })) + + expect(() => renderHeaders(rules.slice(0, 90))).not.toThrow() + expect(() => renderHeaders(rules)).toThrow(/budget is 90/) + }) + + test('no GitHub Pages artifact is published', () => { + const fx = fixture() + fx.writeBundle('component', 'v1', fx.bundleDoc('component', 'v1')) + fx.setManifest({ 'specifications/component/v1': '0.0.0' }) + fx.commit('feat(component): initial') + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + + // CNAME never did anything on an Actions-published Pages site, and on + // Cloudflare it would be served as a static file at /CNAME. + expect(() => readSite(fx, 'CNAME')).toThrow() + expect(() => readSite(fx, '.nojekyll')).toThrow() + }) + + // --------------------------------------------------------------------------- + // The human entry point. + // --------------------------------------------------------------------------- + + test('the root index names every family and its alias', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + fx.writeBundle('listing', 'v1', fx.bundleDoc('listing', 'v1')) + fx.commit('feat(listing): an untagged family') + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const index = readSite(fx, 'index.html') + + expect(index).toContain('href="/component/v1/component.schema.json"') + expect(index).toContain('href="/listing/v1/listing.schema.json"') + // The prose link resolves at the ref the alias actually serves. + expect(index).toContain('/blob/component/v1.0.0/specifications/component/v1/spec.md') + expect(index).toContain('/blob/main/specifications/listing/v1/spec.md') + }) + + test('a family index lists every published version with its checksum', () => { + const fx = fixture() + release(fx, 'component', 'v1', '1.0.0', fx.bundleDoc('component', 'v1')) + release(fx, 'component', 'v1', '1.1.0', fx.bundleDoc('component', 'v1', { minProperties: 1 })) + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const page = readSite(fx, 'component', 'index.html') + const inventory = JSON.parse(readSite(fx, 'component', 'versions.json')) + + for (const version of inventory.versions) { + expect(page).toContain(`/component/v${version.version}/component.schema.json`) + expect(page).toContain(version.sha256) + } + expect(page).toContain('versions.json') + }) + + test('a family index says plainly that an untagged family has released nothing', () => { + const fx = fixture() + fx.writeBundle('component', 'v1', fx.bundleDoc('component', 'v1')) + fx.setManifest({ 'specifications/component/v1': '0.0.0' }) + fx.commit('feat(component): initial') + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + const page = readSite(fx, 'component', 'index.html') + + expect(page).toContain('Nothing has been released') + // versions.json is not written for such a family, so it must not be linked. + expect(page).not.toContain('versions.json') + }) + + test('a not-found page is published', () => { + const fx = fixture() + fx.writeBundle('component', 'v1', fx.bundleDoc('component', 'v1')) + fx.setManifest({ 'specifications/component/v1': '0.0.0' }) + fx.commit('feat(component): initial') + + assembleSite({ repoRoot: fx.root, siteDir: join(fx.root, 'site') }) + + expect(readSite(fx, '404.html')).toContain('Not found') + }) }) diff --git a/tools/src/site.ts b/tools/src/site.ts index 030626e..59f7487 100644 --- a/tools/src/site.ts +++ b/tools/src/site.ts @@ -12,10 +12,17 @@ * version ships. The working tree feeds the alias only, and only until the * major has its first tag. * - * GitHub Pages cannot set Cache-Control, so cache immutability is enforced at - * the Cloudflare edge by a rule keyed on the versioned path shape. That is a - * caching concern; the guarantee that the bytes themselves never change is - * made here, and checked by `task check:published`. + * The origin is Cloudflare Pages, so the cache contract is stated here rather + * than in an edge rule this repository cannot see. `_headers` is generated from + * the same enumeration that writes the tree — there is no second list of paths + * to keep in step — and its rules are deliberately non-overlapping, because + * Pages merges every matching rule and comma-joins duplicate header names + * rather than letting the more specific one win. `assertNoOverlap` holds that + * property over the paths actually written, not over the paths someone + * remembered. See docs/adr/0012. + * + * The guarantee that the bytes themselves never change is made here too, and + * checked by `task check:published`. */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -36,9 +43,9 @@ import { discoverFamilies, type Json, REPO_ROOT, + REPO_URL, relativeToRepo, SITE_DIR, - SITE_HOST, } from './spec.ts' export interface SiteOptions { @@ -49,8 +56,64 @@ export interface SiteOptions { export interface SiteResult { readonly pinned: number readonly aliases: number + readonly pages: number + readonly rules: number +} + +/** One `_headers` block: a path pattern, and the headers it sets on a match. */ +export interface HeaderRule { + /** A Cloudflare Pages source pattern. At most one splat, per their limit. */ + readonly source: string + readonly headers: readonly string[] } +const HEADERS_FILE = '_headers' + +/** + * Cloudflare Pages accepts at most 100 rules. Fail at a budget below that: a + * file over the ceiling is rejected wholesale, and a deploy that silently + * served every pinned path with the wrong cache policy would look like success. + */ +const MAX_HEADER_RULES = 100 +const HEADER_RULE_BUDGET = 90 +/** Cloudflare Pages' per-line limit, spacing and header name included. */ +const MAX_HEADER_LINE = 2000 + +/** A pinned path is published once and never changes. Cache it for a year. */ +const IMMUTABLE = 'Cache-Control: public, max-age=31536000, immutable' +/** An alias moves on release, and an inventory grows. Revalidate quickly. */ +const REVALIDATE = 'Cache-Control: public, max-age=300, must-revalidate' + +/** + * Rules keyed on a path *shape* rather than on one published path. + * + * `/*` restates two headers Cloudflare Pages already sends by default. They are + * pinned rather than inherited because README instructs editors and + * browser-based validators to fetch these URLs cross-origin: that is a + * guarantee this repository makes, and a guarantee resting on a vendor default + * is one that can be withdrawn without a commit here. + * + * The three overlap freely with everything below, and with each other, because + * no rule sets a header name another rule also sets — which is the only + * property that matters when every match is merged. + */ +const SHAPE_RULES: readonly HeaderRule[] = [ + { + source: '/*', + headers: ['Access-Control-Allow-Origin: *', 'X-Content-Type-Options: nosniff'], + }, + { + // What json-schema.org serves for the same kind of document. + source: '/*.schema.json', + headers: ['Content-Type: application/schema+json; charset=utf-8'], + }, + { + // Otherwise served as a download rather than shown. + source: '/*.sha256', + headers: ['Content-Type: text/plain; charset=utf-8'], + }, +] + function write(path: string, contents: string): void { mkdirSync(dirname(path), { recursive: true }) writeFileSync(path, contents, 'utf8') @@ -68,6 +131,92 @@ interface PublishedVersion { readonly sha256: string } +/** A major-version alias, and the ref whose prose it currently corresponds to. */ +interface Alias { + readonly family: string + readonly major: string + readonly path: string + /** The tag the alias serves, or `main` while the major has no tag. */ + readonly ref: string +} + +/** + * Cloudflare Pages' source matching, reduced to what this file emits: a literal + * path, or a pattern with one greedy splat. + */ +export function matchesSource(source: string, path: string): boolean { + const splat = source.indexOf('*') + if (splat === -1) return source === path + const head = source.slice(0, splat) + const tail = source.slice(splat + 1) + return path.length >= head.length + tail.length && path.startsWith(head) && path.endsWith(tail) +} + +function headerName(header: string): string { + return header.slice(0, header.indexOf(':')) +} + +/** + * Fail if two rules that both match a published path set the same header. + * + * Pages has no notion of specificity: it applies every matching rule and joins + * duplicate names with a comma, so a broad pinned-path rule plus a per-alias + * override does not override anything — it emits + * `Cache-Control: public, max-age=31536000, immutable, public, max-age=300, + * must-revalidate` and the alias is cached for a year. This is checked against + * the paths actually written, so a new artifact cannot quietly acquire a second + * opinion about how long it may be cached. + */ +function assertNoOverlap(rules: readonly HeaderRule[], paths: readonly string[]): void { + for (const path of paths) { + const claimed = new Map() + for (const rule of rules) { + if (!matchesSource(rule.source, path)) continue + for (const header of rule.headers) { + const name = headerName(header) + const previous = claimed.get(name) + if (previous !== undefined) { + throw new Error( + `${HEADERS_FILE}: ${path} matches both '${previous}' and '${rule.source}', and ` + + `both set ${name}. Cloudflare Pages merges matching rules and comma-joins ` + + 'duplicate header names, so the result would be neither value.', + ) + } + claimed.set(name, rule.source) + } + } + } +} + +/** Serialize the rules, enforcing Cloudflare's two structural limits. */ +export function renderHeaders(rules: readonly HeaderRule[]): string { + if (rules.length > HEADER_RULE_BUDGET) { + throw new Error( + `${HEADERS_FILE} would carry ${rules.length} rules; the budget is ${HEADER_RULE_BUDGET} ` + + `and Cloudflare Pages rejects a file over ${MAX_HEADER_RULES}. Collapse a path shape ` + + 'into a directory rule rather than raising the budget to the ceiling.', + ) + } + + const lines: string[] = [] + for (const rule of rules) { + if (lines.length > 0) lines.push('') + lines.push(rule.source) + for (const header of rule.headers) lines.push(` ${header}`) + } + + for (const line of lines) { + if (line.length > MAX_HEADER_LINE) { + throw new Error( + `${HEADERS_FILE}: a line exceeds Cloudflare's ${MAX_HEADER_LINE}-character limit: ` + + `${line.slice(0, 80)}…`, + ) + } + } + + return `${lines.join('\n')}\n` +} + export function assembleSite(options: SiteOptions): SiteResult { const { repoRoot, siteDir } = options @@ -85,10 +234,15 @@ export function assembleSite(options: SiteOptions): SiteResult { rmSync(siteDir, { recursive: true, force: true }) mkdirSync(siteDir, { recursive: true }) - // Pages runs Jekyll by default, which would swallow files beginning with an - // underscore and rewrite others. - write(join(siteDir, '.nojekyll'), '') - write(join(siteDir, 'CNAME'), `${SITE_HOST}\n`) + /** Every path served, in URL form. `_headers` is checked against exactly this. */ + const served: string[] = [] + /** Cache-Control rules, one per published path or per pinned release. */ + const cacheRules: HeaderRule[] = [] + + const emit = (path: string, contents: string): void => { + write(join(siteDir, ...path.split('/')), contents) + served.push(`/${path}`) + } // --------------------------------------------------------------------------- // Pinned paths, straight from the tags. @@ -100,11 +254,14 @@ export function assembleSite(options: SiteOptions): SiteResult { for (const release of releases) { const loaded = loadRelease(repoRoot, release, ledger) const fileName = `${release.family}.schema.json` - const dir = join(siteDir, release.family, `v${release.version}`) + const dir = `${release.family}/v${release.version}` - write(join(dir, fileName), loaded.published) - write(join(dir, `${fileName}.sha256`), checksumFile(loaded.publishedSha256, fileName)) - console.log(` ✓ /${release.family}/v${release.version}/${fileName} (immutable)`) + emit(`${dir}/${fileName}`, loaded.published) + emit(`${dir}/${fileName}.sha256`, checksumFile(loaded.publishedSha256, fileName)) + // One rule for the release, not one per file: the sidecar is as immutable + // as the bytes it attests, and a directory rule says so in half the budget. + cacheRules.push({ source: `/${dir}/*`, headers: [IMMUTABLE] }) + console.log(` ✓ /${dir}/${fileName} (immutable)`) pinned += 1 // `releases` is sorted oldest-first, so the last write per major wins. @@ -126,13 +283,20 @@ export function assembleSite(options: SiteOptions): SiteResult { // its first tag — so nothing regresses pre-release, and the alias stops // tracking `main` automatically the moment a family is tagged. // --------------------------------------------------------------------------- - let aliases = 0 + const aliases: Alias[] = [] - const writeAlias = (family: string, major: string, contents: string, origin: string): void => { - const fileName = `${family}.schema.json` - write(join(siteDir, family, major, fileName), contents) - console.log(` ✓ /${family}/${major}/${fileName} (alias → ${origin})`) - aliases += 1 + const writeAlias = ( + family: string, + major: string, + contents: string, + ref: string, + origin: string, + ): void => { + const path = `${family}/${major}/${family}.schema.json` + emit(path, contents) + cacheRules.push({ source: `/${path}`, headers: [REVALIDATE] }) + aliases.push({ family, major, path: `/${path}`, ref }) + console.log(` ✓ /${path} (alias → ${origin})`) } for (const [key, release] of newestByMajor) { @@ -140,7 +304,7 @@ export function assembleSite(options: SiteOptions): SiteResult { const loaded = loadRelease(repoRoot, release, ledger) // The tag's own bytes, not the pinned copy: the alias URL is what the // committed bundle's `$id` already names, so these need no restamping. - writeAlias(family, major, loaded.source.toString('utf8'), release.tag) + writeAlias(family, major, loaded.source.toString('utf8'), release.tag, release.tag) } for (const family of discoverFamilies(repoRoot)) { @@ -149,7 +313,13 @@ export function assembleSite(options: SiteOptions): SiteResult { console.log(` · ${family.name}/${family.major}: no bundle built — skipped`) continue } - writeAlias(family.name, family.major, readFileSync(family.bundlePath, 'utf8'), 'working tree') + writeAlias( + family.name, + family.major, + readFileSync(family.bundlePath, 'utf8'), + 'main', + 'working tree', + ) } // --------------------------------------------------------------------------- @@ -157,38 +327,270 @@ export function assembleSite(options: SiteOptions): SiteResult { // --------------------------------------------------------------------------- for (const [family, versions] of versionsByFamily) { const latest = versions[versions.length - 1] as PublishedVersion - write( - join(siteDir, family, 'versions.json'), + emit( + `${family}/versions.json`, canonicalJson({ family, latest: latest.version, versions: versions.map((v) => ({ ...v })) as unknown as Json, }), ) + cacheRules.push({ source: `/${family}/versions.json`, headers: [REVALIDATE] }) console.log(` ✓ /${family}/versions.json (${versions.length} version(s))`) } // The ledger is published so a consumer can verify a vendored copy offline // without a checkout. - write(join(siteDir, LEDGER_FILE), serializeLedger(ledger)) + emit(LEDGER_FILE, serializeLedger(ledger)) + cacheRules.push({ source: `/${LEDGER_FILE}`, headers: [REVALIDATE] }) // Deliberately tag-independent: `catalog.json` is committed and CI checks it // is current on checkouts that may carry no tags at all. - write(join(siteDir, 'catalog.json'), canonicalJson(buildCatalog(repoRoot))) + emit('catalog.json', canonicalJson(buildCatalog(repoRoot))) + cacheRules.push({ source: '/catalog.json', headers: [REVALIDATE] }) console.log(' ✓ /catalog.json') + // --------------------------------------------------------------------------- + // The human entry point. Generated rather than committed for the reason + // `docs/traceability.md` is: a page someone has to remember to update is a + // page that is wrong. Deliberately thin — this is a registry, and the + // normative prose stays in each family's spec.md. + // --------------------------------------------------------------------------- + const families = [ + ...new Set([...aliases.map((a) => a.family), ...versionsByFamily.keys()]), + ].sort() + let pages = 0 + + emit('index.html', renderIndex(families, aliases, versionsByFamily)) + pages += 1 + for (const family of families) { + emit( + `${family}/index.html`, + renderFamilyIndex( + family, + aliases.filter((a) => a.family === family), + versionsByFamily.get(family) ?? [], + ), + ) + pages += 1 + } + emit('404.html', renderNotFound()) + pages += 1 + console.log(` ✓ ${pages} page(s)`) + + // The pages take no rule of their own. Cloudflare Pages already serves an + // uncontested asset as `public, max-age=0, must-revalidate`, which is what an + // index wants; and a rule would have to guess whether the request path is + // `/component/` or `/component/index.html`, since Pages redirects between the + // two. Adding one would buy nothing and could miss. + + // --------------------------------------------------------------------------- + // The cache contract. + // --------------------------------------------------------------------------- + const rules = [...SHAPE_RULES, ...cacheRules.sort((a, b) => a.source.localeCompare(b.source))] + assertNoOverlap(rules, served) + write(join(siteDir, HEADERS_FILE), renderHeaders(rules)) + console.log(` ✓ /${HEADERS_FILE} (${rules.length} rule(s))`) + for (const notice of pendingNotices(repoRoot)) { console.log(` · ${notice}`) } - return { pinned, aliases } + return { pinned, aliases: aliases.length, pages, rules: rules.length } +} + +// ============================================================================= +// Pages +// ============================================================================= + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +const STYLE = [ + ':root{color-scheme:light dark;--muted:#5c5f66;--rule:#8884}', + '@media(prefers-color-scheme:dark){:root{--muted:#9aa0a6}}', + 'body{margin:0 auto;max-width:54rem;padding:2.5rem 1.25rem 4rem;', + 'font:16px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}', + 'h1{font-size:1.5rem;margin:0 0 .25rem}h2{font-size:1rem;margin:2.5rem 0 .5rem}', + 'p{margin:.5rem 0}.lead,footer,.muted{color:var(--muted)}', + 'table{border-collapse:collapse;width:100%;margin:1rem 0;font-size:.9375rem}', + 'th,td{border-bottom:1px solid var(--rule);padding:.5rem .6rem;text-align:left;', + 'vertical-align:top}', + 'th{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}', + 'code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.875rem}', + '.hash{color:var(--muted);word-break:break-all;font-size:.8125rem}', + 'footer{margin-top:3.5rem;padding-top:1rem;border-top:1px solid var(--rule);font-size:.875rem}', +].join('') + +function page(title: string, body: string): string { + return [ + '', + '', + '', + '', + `${escapeHtml(title)}`, + ``, + body, + '', + ].join('\n') +} + +/** A `spec.md` on GitHub, at the ref the reader is actually looking at. */ +function proseUrl(family: string, major: string, ref: string): string { + return `${REPO_URL}/blob/${ref}/specifications/${family}/${major}/spec.md` +} + +function link(href: string, text: string): string { + return `${escapeHtml(text)}` +} + +function renderIndex( + families: readonly string[], + aliases: readonly Alias[], + versionsByFamily: ReadonlyMap, +): string { + const rows = families.map((family) => { + const alias = aliases.find((a) => a.family === family) + const versions = versionsByFamily.get(family) ?? [] + const latest = versions[versions.length - 1] + return [ + '', + `${link(`/${family}/`, family)}`, + `${alias === undefined ? '' : `${link(alias.path, alias.path)}`}`, + `${latest === undefined ? 'unreleased' : escapeHtml(latest.version)}`, + `${versions.length === 0 ? '' : link(`/${family}/versions.json`, 'versions.json')}`, + `${alias === undefined ? '' : link(proseUrl(family, alias.major, alias.ref), 'spec.md')}`, + '', + ].join('') + }) + + return page( + 'Musher schemas', + [ + '

Musher schemas

', + '

Canonical JSON Schema 2020-12 bundles for the Musher document families.', + 'This host serves the schemas; the normative prose, the conformance suite and the', + `publication ledger live in ${link(REPO_URL, 'musher-dev/spec')}.

`, + '', + '', + '', + `${rows.join('')}`, + '
FamilyAliasLatestVersionsProse
', + '

An alias moves within its major version as backward-compatible additions ship.', + 'Automation must pin an exact version instead — those paths are rebuilt from their git', + 'tags on every deploy, carry an $id naming that exact URL, and never change.

', + `
${[ + link('/catalog.json', 'catalog.json'), + link('/published.json', 'published.json'), + link(`${REPO_URL}/releases`, 'releases'), + link(`${REPO_URL}/blob/main/LICENSE`, 'Apache-2.0'), + ].join(' · ')}
`, + ].join('\n'), + ) +} + +function renderFamilyIndex( + family: string, + aliases: readonly Alias[], + versions: readonly PublishedVersion[], +): string { + const aliasRows = aliases.map((alias) => + [ + '', + `${link(alias.path, alias.path)}`, + `${escapeHtml(alias.major)}`, + `${alias.ref === 'main' ? 'unreleased — tracks main' : `${escapeHtml(alias.ref)}`}`, + `${link(proseUrl(family, alias.major, alias.ref), 'spec.md')}`, + '', + ].join(''), + ) + + const versionRows = [...versions].reverse().map((version) => { + const path = new URL(version.url).pathname + return [ + '', + `${escapeHtml(version.version)}`, + `${link(path, path)}`, + `${escapeHtml(version.tag)}`, + `${escapeHtml(version.sha256)}`, + '', + ].join('') + }) + + const published = + versions.length === 0 + ? [ + '

Nothing has been released. Until this family is tagged its alias', + 'serves what is committed on main, and no exact-version URL exists.

', + ] + : [ + '', + '', + `${versionRows.join('')}`, + '
VersionURLTagSHA-256
', + '

Every exact-version URL is immutable and is accompanied by a', + '.sha256 sidecar in sha256sum format.

', + ] + + // A family removed from the working tree keeps serving what it published, so + // it can reach here with releases and no alias. Saying so beats an empty table. + const alias = + aliasRows.length === 0 + ? [ + '

This family is no longer authored here. Its published versions', + 'remain served — nothing is ever unpublished — but no alias tracks it.

', + ] + : [ + '', + '', + `${aliasRows.join('')}`, + '
URLMajorServingProse
', + ] + + return page( + `${family} schemas`, + [ + `

${link('/', 'Musher schemas')}

`, + `

${escapeHtml(family)}

`, + '

Alias

', + ...alias, + '

Published versions

', + ...published, + `
${[ + // Written only for a family that has released, so linked only then. + ...(versions.length === 0 ? [] : [link(`/${family}/versions.json`, 'versions.json')]), + link('/published.json', 'published.json'), + ].join(' · ')}
`, + ].join('\n'), + ) +} + +function renderNotFound(): string { + return page( + 'Not found', + [ + '

Not found

', + '

No schema is published at this path.

', + '

This host serves two path shapes per family —', + '/<family>/v1/<family>.schema.json for the moving alias and', + '/<family>/v1.2.0/<family>.schema.json for an exact version.', + 'A version that was never released has no URL; nothing is ever unpublished.

', + `

${link('/', 'Index')}

`, + ].join('\n'), + ) } function main(): void { const result = assembleSite({ repoRoot: REPO_ROOT, siteDir: SITE_DIR }) console.log( `\nSite assembled at ${relativeToRepo(SITE_DIR)}: ` + - `${result.aliases} alias path(s), ${result.pinned} immutable path(s).`, + `${result.aliases} alias path(s), ${result.pinned} immutable path(s), ` + + `${result.pages} page(s), ${result.rules} header rule(s).`, ) } diff --git a/tools/src/spec.ts b/tools/src/spec.ts index 9072ae8..eecca93 100644 --- a/tools/src/spec.ts +++ b/tools/src/spec.ts @@ -13,11 +13,13 @@ export const SPECIFICATIONS_DIR = join(REPO_ROOT, 'specifications') export const CONFORMANCE_DIR = join(REPO_ROOT, 'conformance') export const SITE_DIR = join(REPO_ROOT, 'site') -export const SCHEMA_ORIGIN = 'https://schemas.musher.dev' -export const METASCHEMA = 'https://json-schema.org/draft/2020-12/schema' - /** The public hostname the published schemas are served from. */ export const SITE_HOST = 'schemas.musher.dev' +export const SCHEMA_ORIGIN = `https://${SITE_HOST}` +export const METASCHEMA = 'https://json-schema.org/draft/2020-12/schema' + +/** This repository, for the links the published pages make back to the prose. */ +export const REPO_URL = 'https://github.com/musher-dev/spec' export interface Family { /** Family name, e.g. `component`. */