From 6139730656d817c00d9243c82764367b38019299 Mon Sep 17 00:00:00 2001 From: Jack Steam Date: Fri, 13 Nov 2026 15:05:36 -0600 Subject: [PATCH 1/2] Extract CMS into standalone service and support cross-repo development --- .changeset/standalone-cms.md | 5 + .github/workflows/ci.yml | 39 ++ .github/workflows/deploy.yml | 4 +- .github/workflows/preview.yml | 4 +- .oxfmtrc.json | 1 - .oxlintrc.json | 6 +- README.md | 80 ++- apps/cms/.dev.vars.example | 2 - apps/cms/.gitignore | 5 - apps/cms/.oxfmtrc.json | 4 - apps/cms/.oxlintrc.json | 3 - apps/cms/README.md | 87 --- apps/cms/bun.lock | 581 ------------------ apps/cms/migrations/0001_init.sql | 46 -- apps/cms/package.json | 43 -- apps/cms/scripts/dev-e2e.ts | 95 --- apps/cms/scripts/seed.ts | 69 --- apps/cms/scripts/tsconfig.json | 15 - apps/cms/seed/en.json | 719 ----------------------- apps/cms/src/app.ts | 59 -- apps/cms/src/auth.ts | 42 -- apps/cms/src/catalog-schema.ts | 9 - apps/cms/src/catalog.ts | 233 -------- apps/cms/src/content.ts | 42 -- apps/cms/src/cors.ts | 39 -- apps/cms/src/coverage.ts | 69 --- apps/cms/src/db.ts | 86 --- apps/cms/src/env.ts | 6 - apps/cms/src/fields.ts | 81 --- apps/cms/src/http.ts | 79 --- apps/cms/src/index.ts | 4 - apps/cms/src/locales.ts | 130 ---- apps/cms/src/placeholders.ts | 15 - apps/cms/src/routes/admin.ts | 516 ---------------- apps/cms/src/routes/public.ts | 101 ---- apps/cms/src/seed.ts | 34 -- apps/cms/src/write.ts | 108 ---- apps/cms/test/admin-locales.test.ts | 297 ---------- apps/cms/test/admin-reports.test.ts | 176 ------ apps/cms/test/admin-translations.test.ts | 261 -------- apps/cms/test/auth.test.ts | 80 --- apps/cms/test/catalog.test.ts | 55 -- apps/cms/test/cors.test.ts | 76 --- apps/cms/test/env.d.ts | 13 - apps/cms/test/fields.test.ts | 102 ---- apps/cms/test/fixtures.ts | 23 - apps/cms/test/health.test.ts | 11 - apps/cms/test/helpers.ts | 114 ---- apps/cms/test/http.test.ts | 30 - apps/cms/test/locales.test.ts | 139 ----- apps/cms/test/public-content.test.ts | 171 ------ apps/cms/test/public-locales.test.ts | 58 -- apps/cms/test/seed.test.ts | 60 -- apps/cms/test/setup.ts | 4 - apps/cms/test/worker.test.ts | 31 - apps/cms/tsconfig.json | 17 - apps/cms/vitest.config.ts | 17 - apps/cms/wrangler.jsonc | 66 --- bun.lock | 188 +++--- docs/README.md | 7 + docs/cms-extraction.md | 46 ++ docs/designs/cms.md | 609 +------------------ docs/plans/cms.md | 337 +---------- docs/runbooks/cms.md | 303 +--------- package.json | 4 +- playwright.config.ts | 16 +- scripts/cms-repo.mjs | 13 + scripts/cms-revision.mjs | 7 + scripts/cms-snapshot.mjs | 5 +- scripts/dev-stack.mjs | 171 ++++++ scripts/local-contract.mjs | 88 +++ scripts/local-contract.test.mjs | 42 ++ scripts/processes.mjs | 99 ++++ scripts/processes.test.mjs | 32 + scripts/review.sh | 115 +--- src/lib/copy/copy.ts | 12 +- src/lib/server/cms/locale.ts | 5 +- src/lib/server/cms/source.ts | 7 +- svelte.config.js | 2 - vite.config.ts | 15 +- 80 files changed, 754 insertions(+), 6631 deletions(-) create mode 100644 .changeset/standalone-cms.md delete mode 100644 apps/cms/.dev.vars.example delete mode 100644 apps/cms/.gitignore delete mode 100644 apps/cms/.oxfmtrc.json delete mode 100644 apps/cms/.oxlintrc.json delete mode 100644 apps/cms/README.md delete mode 100644 apps/cms/bun.lock delete mode 100644 apps/cms/migrations/0001_init.sql delete mode 100644 apps/cms/package.json delete mode 100644 apps/cms/scripts/dev-e2e.ts delete mode 100644 apps/cms/scripts/seed.ts delete mode 100644 apps/cms/scripts/tsconfig.json delete mode 100644 apps/cms/seed/en.json delete mode 100644 apps/cms/src/app.ts delete mode 100644 apps/cms/src/auth.ts delete mode 100644 apps/cms/src/catalog-schema.ts delete mode 100644 apps/cms/src/catalog.ts delete mode 100644 apps/cms/src/content.ts delete mode 100644 apps/cms/src/cors.ts delete mode 100644 apps/cms/src/coverage.ts delete mode 100644 apps/cms/src/db.ts delete mode 100644 apps/cms/src/env.ts delete mode 100644 apps/cms/src/fields.ts delete mode 100644 apps/cms/src/http.ts delete mode 100644 apps/cms/src/index.ts delete mode 100644 apps/cms/src/locales.ts delete mode 100644 apps/cms/src/placeholders.ts delete mode 100644 apps/cms/src/routes/admin.ts delete mode 100644 apps/cms/src/routes/public.ts delete mode 100644 apps/cms/src/seed.ts delete mode 100644 apps/cms/src/write.ts delete mode 100644 apps/cms/test/admin-locales.test.ts delete mode 100644 apps/cms/test/admin-reports.test.ts delete mode 100644 apps/cms/test/admin-translations.test.ts delete mode 100644 apps/cms/test/auth.test.ts delete mode 100644 apps/cms/test/catalog.test.ts delete mode 100644 apps/cms/test/cors.test.ts delete mode 100644 apps/cms/test/env.d.ts delete mode 100644 apps/cms/test/fields.test.ts delete mode 100644 apps/cms/test/fixtures.ts delete mode 100644 apps/cms/test/health.test.ts delete mode 100644 apps/cms/test/helpers.ts delete mode 100644 apps/cms/test/http.test.ts delete mode 100644 apps/cms/test/locales.test.ts delete mode 100644 apps/cms/test/public-content.test.ts delete mode 100644 apps/cms/test/public-locales.test.ts delete mode 100644 apps/cms/test/seed.test.ts delete mode 100644 apps/cms/test/setup.ts delete mode 100644 apps/cms/test/worker.test.ts delete mode 100644 apps/cms/tsconfig.json delete mode 100644 apps/cms/vitest.config.ts delete mode 100644 apps/cms/wrangler.jsonc create mode 100644 docs/cms-extraction.md create mode 100644 scripts/cms-repo.mjs create mode 100644 scripts/cms-revision.mjs create mode 100644 scripts/dev-stack.mjs create mode 100644 scripts/local-contract.mjs create mode 100644 scripts/local-contract.test.mjs create mode 100644 scripts/processes.mjs create mode 100644 scripts/processes.test.mjs diff --git a/.changeset/standalone-cms.md b/.changeset/standalone-cms.md new file mode 100644 index 0000000..c06929e --- /dev/null +++ b/.changeset/standalone-cms.md @@ -0,0 +1,5 @@ +--- +"web": patch +--- + +Consume the standalone CMS contract release and support cross-repository local development and browser checks. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daa5665..772ba54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,42 @@ jobs: - run: bun run build - run: bunx wrangler deploy --env staging --dry-run - run: bunx wrangler deploy --env alpha --dry-run + browser: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + path: web + - uses: actions/setup-node@v4 + with: + node-version: 24.10.0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - run: bun install --frozen-lockfile + working-directory: web + - name: Read pinned CMS revision + id: cms + working-directory: web + run: node scripts/cms-revision.mjs >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v4 + with: + repository: AstrologyEngine/cms + ref: ${{ steps.cms.outputs.revision }} + path: cms + persist-credentials: false + - run: bun install --frozen-lockfile && bun run contract:build + working-directory: cms + - run: bun run sync && bun test verification + working-directory: web + - run: bunx playwright install --with-deps chromium + working-directory: web + - run: bun run test:browser + working-directory: web + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: local-browser-results + path: | + web/playwright-report/ + web/test-results/ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 30a0c38..0e0d6d6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -13,7 +13,7 @@ jobs: if: github.event_name == 'release' && github.event.release.prerelease uses: ./.github/workflows/ci.yml deploy: - if: always() && (github.event_name == 'push' || needs.checks.result == 'success') + if: vars.CMS_DEPLOY_ENABLED == 'true' && always() && (github.event_name == 'push' || needs.checks.result == 'success') needs: checks runs-on: ubuntu-24.04 environment: ${{ github.event_name == 'push' && 'staging' || 'alpha' }} @@ -38,6 +38,8 @@ jobs: bun-version: 1.3.14 - run: bun install --frozen-lockfile - run: bun run sync && bun run build + env: + CMS_SNAPSHOT_ORIGIN: https://cms-${{ env.TARGET }}.astrologyengine.dev - run: bunx wrangler deploy --env "$TARGET" --dry-run - name: Recheck current staging head if: github.event_name == 'push' diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index ad0e322..89698b4 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: false jobs: preview: - if: github.event.pull_request.head.repo.full_name == github.repository + if: github.event.pull_request.head.repo.full_name == github.repository && (github.event.action == 'closed' || vars.CMS_DEPLOY_ENABLED == 'true') runs-on: ubuntu-24.04 timeout-minutes: 25 env: @@ -55,7 +55,7 @@ jobs: echo "id=$id" >> "$GITHUB_OUTPUT" gh api -X POST "repos/$REPO/deployments/$id/statuses" -f state=in_progress bun run sync - bun run build + CMS_SNAPSHOT_ORIGIN=https://cms-staging.astrologyengine.dev bun run build - name: Upload preview id: upload if: steps.deploy.outputs.id != '' diff --git a/.oxfmtrc.json b/.oxfmtrc.json index f9b571b..4778d2d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -3,7 +3,6 @@ "ignorePatterns": [ "static/engine/**", "static/atlas/**", - "apps/**", "docs/editorial/natal-chart-review.md" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 47e8fb1..30d5a22 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,7 +1,3 @@ { - "ignorePatterns": [ - "static/engine/**", - "static/calculation-worker.js", - "apps/**" - ] + "ignorePatterns": ["static/engine/**", "static/calculation-worker.js"] } diff --git a/README.md b/README.md index d3c4d96..8fb9a82 100644 --- a/README.md +++ b/README.md @@ -8,52 +8,50 @@ Use Node 24.10.0 and Bun 1.3.14. ```sh bun install --frozen-lockfile -(cd apps/cms && bun install --frozen-lockfile) bun run sync bun run dev ``` -Before committing: `bun run check`, `bun run check:ts`, `bun run lint`, -`bun run format:check`, `bun run build`, both environment dry runs, and +Web can install, check and build independently. Without a running CMS, local +requests use baseline copy bundled from the pinned contract package. + +For both services, clone [AstrologyEngine/cms](https://github.com/AstrologyEngine/cms) +as sibling `../cms` and run: + +```sh +bun run dev:stack +# Other checkout/ports: +CMS_REPO_PATH=/path/to/cms WEB_PORT=5174 CMS_PORT=8789 bun run dev:stack +``` + +The launcher installs frozen dependencies, migrates/seeds persistent local D1, +watches and temporarily links the local contract, and starts CMS then web. +Ctrl+C stops its processes and restores the pinned package. Each service can +also run separately: start CMS there, then set `CMS_ORIGIN` when starting web +(default `http://127.0.0.1:8787`). `scripts/review.sh` runs checks and browser +tests before starting the same stack; `--serve` starts it without checks. + +Before committing: `bun run sync`, `bun run check`, `bun run check:ts`, +`bun run lint`, `bun run format:check`, `node --test scripts/*.test.mjs`, +`bun test verification`, `bun run build`, both environment dry runs and `bun run test:browser` (install Chromium with `bunx playwright install chromium`). ## CMS copy -The [CMS Worker](apps/cms/README.md) stores website text and reference content. -See the [design](docs/designs/cms.md), [plan](docs/plans/cms.md) and -[operations runbook](docs/runbooks/cms.md). - -Start `bun run dev:e2e` in `apps/cms` in a separate terminal for a fresh local -CMS on port 8787. The web server uses `CMS_ORIGIN` (default -`http://127.0.0.1:8787`); deployed Workers use the `CMS` service binding. -The server negotiates `?lang`, the language cookie and `Accept-Language`. -A language switcher appears when multiple locales are enabled. - -Dev and build generate outage snapshots in `static/content/` from the English -seed. Set `CMS_SNAPSHOT_ORIGIN` to the target CMS for deployed builds; a failed -snapshot fetch fails the build. Provision and seed the CMS before deploying the -web service binding, following the runbook. Remote database IDs are placeholders. - -The imported toolchain uses SvelteKit 2.59.1, Cloudflare adapter 7.2.9 and the -official Svelte compiler, with oxfmt and Prettier for formatting. Node and Bun -pins remain unchanged. - -Run `node --test scripts/*.test.mjs` and `bun test verification` alongside the -web checks above. Run `test`, `check`, `lint` and `format:check` in `apps/cms`. -Browser tests start two local CMS instances and three web servers to cover -English, a partial pseudo-locale and CMS outages. `PLAYWRIGHT_PORT` changes the -base web port; `E2E_BASE_URL` uses an existing server; -`PLAYWRIGHT_CHROMIUM_EXECUTABLE` optionally selects a local Chromium executable. - -## Local review runner - -With Node 24.10.0 and Bun 1.3.14 on PATH, run `./scripts/review.sh` to install -locked dependencies, validate both applications, run browser tests, and start -the local web/CMS stack with manual review steps. Use `./scripts/review.sh --serve` -to skip checks and start the stack. Ctrl+C stops its services. - -`REVIEW_WEB_PORT`, `REVIEW_CMS_PORT`, and `PLAYWRIGHT_PORT` configure ports. -Set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to use an existing Chromium; otherwise the -runner downloads it. On Linux, missing browser system libraries require a -one-time `bunx playwright install-deps chromium` installation with administrator -access. The runner itself does not install system packages. +[CMS](https://github.com/AstrologyEngine/cms) owns website copy, the typed +catalog, locale helpers and baseline English seed. Web pins +`@astrologyengine/cms-contract` to an exact public GitHub release asset. + +New keys: update and release CMS, deploy/seed it, then update web's dependency +URL and lockfile before using those keys. Ordinary content edits use the CMS +admin API and need no web release. See the +[CMS runbook](https://github.com/AstrologyEngine/cms/blob/main/docs/runbooks/cms.md). + +Builds bundle outage snapshots from the package's baseline seed by default. +`CMS_SNAPSHOT_ORIGIN` fetches all enabled locales from a live CMS; fetch errors +fail the build. Deployed builds always use their target CMS origin. + +Browser tests require a CMS checkout and use isolated databases for English, +a partial pseudo-locale and CMS outages. `CMS_REPO_PATH` overrides the sibling +path; `PLAYWRIGHT_PORT` changes test ports. `E2E_BASE_URL` tests an existing site +without a local CMS checkout. See [extraction details](docs/cms-extraction.md). diff --git a/apps/cms/.dev.vars.example b/apps/cms/.dev.vars.example deleted file mode 100644 index e277d66..0000000 --- a/apps/cms/.dev.vars.example +++ /dev/null @@ -1,2 +0,0 @@ -# Copy to .dev.vars for local admin access. At least 32 characters. -CMS_ADMIN_TOKEN=local-development-token-change-me-0123456789 diff --git a/apps/cms/.gitignore b/apps/cms/.gitignore deleted file mode 100644 index a75540f..0000000 --- a/apps/cms/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -.wrangler/ -.dev.vars* -!.dev.vars.example -*.sql.tmp diff --git a/apps/cms/.oxfmtrc.json b/apps/cms/.oxfmtrc.json deleted file mode 100644 index ccf8c7c..0000000 --- a/apps/cms/.oxfmtrc.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "printWidth": 80, - "ignorePatterns": [".wrangler/**"] -} diff --git a/apps/cms/.oxlintrc.json b/apps/cms/.oxlintrc.json deleted file mode 100644 index 6eb1ec4..0000000 --- a/apps/cms/.oxlintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "ignorePatterns": [".wrangler/**"] -} diff --git a/apps/cms/README.md b/apps/cms/README.md deleted file mode 100644 index e7558d3..0000000 --- a/apps/cms/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Astrology Engine CMS - -A Cloudflare Worker backed by D1 that holds all Natal Chart Explorer website copy, -per locale. The public API serves anonymous, read-only requests; the admin API -requires a Bearer token. - -- Design: [`docs/designs/cms.md`](../../../docs/designs/cms.md) -- Operations (Cloudflare setup, deploys, content edits, incidents): - [`docs/runbooks/cms.md`](../../../docs/runbooks/cms.md) - -## Setup - -Bun 1.3.14 and Node ≥ 22. - -```sh -bun install --frozen-lockfile -bun run dev:e2e # fresh local D1 → migrate → seed → wrangler dev on :8787 -``` - -Or keep local data between runs: - -```sh -bun run migrate:local && bun run seed:local && bun run dev -``` - -For admin calls locally, copy `.dev.vars.example` to `.dev.vars` (the token must -be at least 32 characters). - -## Checks - -```sh -bun run test # Vitest in workerd against a real local D1 -bun run check # TypeScript (Worker and scripts) -bun run lint -bun run format:check -``` - -## Layout - -| Path | Purpose | -| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `src/catalog.ts` | Every collection and key the site may use, plus the placeholders of each `ui` text. The website type-imports it. | -| `seed/en.json` | English text for every catalog entry; tests enforce exact parity with the catalog. | -| `migrations/` | D1 schema. Additive-only. | -| `src/routes/public.ts` | `GET /v1/locales`, `GET /v1/content/:locale[/:collection/:key]` | -| `src/routes/admin.ts` | Locales, translations, coverage, revisions, catalog | -| `src/locales.ts` | Language-tag rules and fallback chains, shared with the website | -| `scripts/seed.ts` | Inserts missing seed rows (`--local` or `--env `); never overwrites edits | -| `scripts/dev-e2e.ts` | Fresh seeded local CMS, optionally with a `--fixture` of extra locales (used by Playwright) | - -## API quick reference - -Public (no auth; cacheable, `ETag: "v"`): - -```sh -curl localhost:8787/health -curl localhost:8787/v1/locales -curl localhost:8787/v1/content/en -curl localhost:8787/v1/content/en/body/SUN -``` - -Admin (`Authorization: Bearer $CMS_ADMIN_TOKEN`; optional `X-CMS-Actor: `): - -| Method & path | Purpose | -| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `GET /v1/admin/locales` · `POST /v1/admin/locales` · `PATCH`/`DELETE /v1/admin/locales/:code` | Manage locales (new ones start disabled) | -| `GET /v1/admin/entries/:collection/:key` | All stored translations of an entry | -| `PUT`/`DELETE /v1/admin/entries/:collection/:key/translations/:locale` | Edit text (`If-Match: "r"` or `If-None-Match: *` for concurrency) | -| `GET /v1/admin/coverage` | Missing, fallback-served and placeholder-mismatched entries per locale; orphans | -| `GET /v1/admin/revisions` | History, newest first (`collection`, `key`, `locale`, `before`, `limit`) | -| `GET /v1/admin/catalog` | The catalog | - -Field shapes: `body`, `angle`, `sign` and `aspect` take -`{ name, insight, keywords }`; `house` takes `{ insight, keywords }`; `ui` takes -`{ text }`, where `text` must contain exactly the catalog's `{placeholders}`. - -## Adding copy - -Keys are defined in code: - -1. Add the key to `src/catalog.ts`, declaring any placeholders. -2. Add its English text to `seed/en.json`. `bun run test` fails until the two - match. -3. Use it in the website with `t("your.key")`; type checks reject unknown keys. - -Deploying runs the seed, which inserts the new English text without touching -existing edits. See the runbook for the order of deploy steps. diff --git a/apps/cms/bun.lock b/apps/cms/bun.lock deleted file mode 100644 index 3cd92c5..0000000 --- a/apps/cms/bun.lock +++ /dev/null @@ -1,581 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "cms", - "dependencies": { - "hono": "4.13.16", - "zod": "4.6.7", - }, - "devDependencies": { - "@cloudflare/vitest-plugin": "1.1.13", - "@cloudflare/workers-types": "5.20261029.1", - "@types/bun": "1.3.14", - "oxfmt": "0.76.0", - "oxlint": "1.91.0", - "typescript": "6.0.3", - "vitest": "4.1.12", - "wrangler": "4.156.0", - }, - }, - }, - "packages": { - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-z8x4kittQLPsrJWmfoIJyYqs2gNwceGShoUqAJdFRPw3K4kkkKCFHh3Cr4NFRvZb7DPfDS3+JfBjSm3OIyQaJQ=="], - - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zj9fEQhQSKGj8ZTB+X4q4ho4545xWf7qsvJpwREWnBz5BATEFYtStKRgiwYOZvxQ7ORlBEgSj7pmDzuljfV1mQ=="], - - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZy8GpUXaWpic5JPMAvLmaBz95Sx0naZl6hptBPLOMiYmZYiME7jAt6pv8o/tjQqPNWKb+7jKzZhrYx6UqM5MQ=="], - - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U59+a9lVpoRp9enRhTE9GssL3qB1k09CYagRzTfs3dfknopMYV0x0xHZv7Pls3SNMqHlyHR+ufmY76CYafQoLw=="], - - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J1R3YJHPdQJ2RQvzMdTVDWyizhihGiRGGAeEZxtGp629j5pcT5ypSLSiBflopD7o7tmiMD+xlNfhIVmjcHKMVQ=="], - - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x9qTg6zHoqeyzmFVlIjSb9WlhZdoXaayMTeQeDeUEfKA484AgPJz7f9chLjDMONA6wUUX0FJxnVHsYgX5Zv0+g=="], - - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-303gbamm7NDllTtkeaX+8F5Da+KcSBr64mqELQEFg9XgnjfLX3YBCHUcOfgIlVTSybesy43eoeKai8oGr0P6UA=="], - - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qFZqWVkEzuxWMtUkCtHxsnohXHnUMBQr/ZIxF0KmlCSt8de/bc1lbpVgpqjb3QxCADQHG/O1s3JcowcBF2sqdw=="], - - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ROiAid0UH2GN5lMMX2JTgKlOKx1rN+6a1WxIuBsU3j5A3MuB9U5XkREMW76XBbAImnH45mfOCFcSIwb+drcVYg=="], - - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-Oyd/9ObdM2RYkZ7yzF/GyvVdbNgJcRG8BFVwnAVo3ooqzaem4E2p5eTIGB09FecQD+Q5HQEANOTEnIZTO6rBAQ=="], - - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-86DOpmPDR5UzHSOB8y5HlOpYAIBtMx+6L72IyousNx54q1Kz61aykB4ScovFYivZUak+7Ww6tPBUjarluzYKLw=="], - - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-u6omjjFooKfnHLBI+3UtBVInAeuEhoM/RAIw/OiFop2QQgOan5zDfqPR6guMaylt68L60kV+by07Ibp87KDjrw=="], - - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JNk/7HaPDJjy5kih3vq6b71Qif3tHx1ZFACGBzeHeNzyKaIpar4eFGya89GR398K2rwX0eEVmEq1npBPlEkkmg=="], - - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-MDLrOQPXDuPKvPUCvpP3hC6kO72dsYxfpYiR08Nf5DiM75M0d8+D26JwT4uHDf0bcflvlGHcd58Hn2HpxkHUUQ=="], - - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2CcICDrQKqMAOFuzmts/8Ov5iKb+DW/QrW4MFsl2bnRg86uoiUvtqUkHqNSY19Shegc8pBHiXMSEHDjIg94o0A=="], - - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-NjZLJg+tQpMWVSyeWBBKCpib7H/uLq84ZzxCvY5eqHzSaItuzCECGA9MBiZwmW8fnkBIfppg49Ib//LSCGAp8Q=="], - - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0vCMaX/qloc7uaKBDRh+62SBW8EqmhS7c8/al+PxD594ao4lyEWdcoEmVoBgnCNVW07GDpg+9uPV7x6ta9mljw=="], - - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.91.0", "", { "os": "android", "cpu": "arm" }, "sha512-eOqJUUS1MCqIoUUxMIkuwaqIdKaHr1SXYA0CQI95UUrF8jwQylQH33bBmnqhZfvWXKlRB2Ylx/+Sbk/ctEqzZQ=="], - - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.91.0", "", { "os": "android", "cpu": "arm64" }, "sha512-vIJU4lRxK0EhbBt+iR3rkq3pio4I+V5Gshwjei3plNjN+CIq0WxWHzYVI06J9KN8Ar3hyNSFtzJAjVRLZOYpjg=="], - - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.91.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tLy76MurwAOpQt5QVMkJUgexBSWhB7qdvAYb14bc0xntZS6f55aKicbZoxZ/QlmAbnrdlXeD5+IjwY8EZ9RWTg=="], - - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.91.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zPeMmjAqm5vXxbTp/JqYuc9gGSbUuEaeCAkFHB4TCRLTlfJGD//Fkx03YvZHCHZJ7ideLiiLcae+3f2I27qnaA=="], - - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.91.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-psOR9+xGMXn0hj4KY5M2kjSQ1n0TCMdYbPOraoV0iQyl7mw2oBBYGUM2+ma40m1YLVrktTBOta4FjrmcOHDhrg=="], - - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1PNA1j4ETxawvxfz6ueDxWgQgWkXPbv44DWZmC7LR07GqojGFDOwWbosOT6/AYiJr7zMXEhn2dPWMkyFFOFpUQ=="], - - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jcNePVpca37lTbHmiPMnVbPAAW0rH9kAoDjsDv6vESEX9pzazIR1q5SyYuc1rQzdjHtc2VVCDN9jDh6tHcOHyA=="], - - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.91.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OowlLroUTzKq5OBvbu6iegch8SJ2DivXFCukzPWURwvcJhMj7/P0CUGH5qrDlxT1Kh48dcxqYqrvE8qK6xTLKQ=="], - - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.91.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QF/5Ge8yGpWg5s4awMNHfVKh1WOmk591Qg082i6CHenXYBa+W1E5RwWZXaKUOw4Ko7CCjqg3TfBc0AzhnqblYg=="], - - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-dXlsKlNhzJQX1nageGKnvWe1D7uTng249hQM80CG/YPZ8F/VZHbxVLhImgurdWBOmu86XOemValLjrW6QIOM/Q=="], - - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-tznkj5VrWNRUfymkaLcJetgMLbP9suL5ob/m7j0Fdbnm5u3Li/Ezo/YcBixiuwdL1sg0oGpyTe86It0J6T+Lxg=="], - - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.91.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sNvfLiUYQkVsLNUgIW24AEYTHSd2zZ1vAxXssPdjuQHTtCpRvQihYri6d5z5FY7w/QrcnB5+g9E+/sqmZq8tfA=="], - - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.91.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4hZ4Z9K+OkcTpAHDoUTMg/8MModbM0aocrl9KnGzp9h5iKqjrXraEUZorYoVCT7xoK93CAHXgTndxRBrOjH/fA=="], - - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.91.0", "", { "os": "none", "cpu": "arm64" }, "sha512-gtqn4b0kLpGe46Qh7NHiR86wZpd+TLaesO106s7V3RG33r63VB94LW5qPKEDJX64osh3xfjnPFn1M0484bC7wQ=="], - - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.91.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KQItBdsyHN28U3z/bEek6h9pvG3Uqv564QYO8K1bE/V7YRZgpHlRnfNKVPHkk4sfYKyPzEjCgW80QkImOLvJog=="], - - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.91.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XFKqbbsQVmTgw+CyBKuzSZtAa45viom1naFfulKmkTFdDyDaBQaBqEz5J61TI8iwBynU+F8q7qPD+9mplZRhEg=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.91.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DmpK0PthG66Bcz5Y4JqNbzXltzyLQtg3ofYpd25+ejm7kQYUM78VL1uIP/ZFct2781mwUyBX37LatFPCTVp35A=="], - - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="], - "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], - - "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.2", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ=="], - - "@cloudflare/vitest-plugin": ["@cloudflare/vitest-plugin@1.1.13", "", { "dependencies": { "cjs-module-lexer": "1.2.3", "esbuild": "0.28.1", "miniflare": "5.20260918.0-alpha", "wrangler": "4.135.0", "zod": "4.4.3" }, "peerDependencies": { "@vitest/runner": "^4.1.0", "@vitest/snapshot": "^4.1.0", "vitest": "^4.1.0" } }, "sha512-0yw65BvdySpKlK73YcJE/VXn2gbwyPB8dSpxFOUQbh4Kg2Yx6msxTEzQ+92dNH5q/84FNGzWcJ2aJjHlWCzGkA=="], - - "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20261023.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-lsEx0i7nR0PNG/erUFFHmQmiidEhLfDE5uQmAL9421/y4/ycuxDDOSaxu3VGGYFFPK5sduyUeMmjIlX4CwOBFg=="], - - "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20261023.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5e79fHgabdDD8EgmjOhJ1S+xMsf6DbC7fL7IgTBizMnzOp0P2isDn7pYmUxI9AJZE9FVQ4RUilH/I36YNg+GQQ=="], - - "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20261023.1", "", { "os": "linux", "cpu": "x64" }, "sha512-VHQ6jKFSihjR2+x1buby8XTdBLLJztVnYiB7OLOTYO8aDXB+c1Rd2410PdgFuFYc4AgFEk6PCJLyCLL5L1KBnw=="], - - "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20261023.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ilpRHzfDN8YFRM5CLRKuFw40x/LJBS89yVdhdQmxehlVDuBqjKpJB++zPFbTgKRRFaNupgnXc8RHktXAIhgCmQ=="], - - "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20261023.1", "", { "os": "win32", "cpu": "x64" }, "sha512-279WLVjNsMycAwr0EZNiNEf0l2SY6NUtsN80NwZzbTHzakmiN+WSx4w38muxPm+PAU0oSPPAPXKVD+H6Hya8xw=="], - - "@cloudflare/workers-types": ["@cloudflare/workers-types@5.20261029.1", "", {}, "sha512-HwLwogkO/PnT4oXC8Y6wMQpgYbT3TEbWHytHV56F2tz9eLJBcgf4/VMjk5FYriAYHX6JOutS+tU6miuopKfCKw=="], - - "@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.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], - - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], - - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], - - "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="], - - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j3UAXzCJoWX9bZLaICj6DfwkI/2fzja9RpiCIbIN+2CQAcKoY+5SH5pQUcECvn32mQm17NSPE9YqTAwYx1seZg=="], - - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-L0/hW1MdMrgsvuLGp9YEQOYOGj5xPHa8wVRtybdd5av03PV4qSpAq3pyfC7sn7UCA8ynRhMRTAa9Kr9nV4LGZg=="], - - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.91.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TJi78UX6R+9qZ23ZKtzN6adfW83bjY0NXsCRMBRH6H7IizaMXmZbdRAtjedMkx77bT46YQH0csawsY9lwha8MA=="], - - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.91.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eA25ETvimhUZ89y5BcunNY7yv7qwG2I7V7dFQAU+mIEqZ57gBCXdIERl0yXOEt43zjsw0yK2gRKTc+twF706wQ=="], - - "@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=="], - - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="], - - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - - "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], - - "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], - - "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], - - "@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="], - - "@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="], - - "@vitest/expect": ["@vitest/expect@4.1.12", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.12", "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.12", "", { "dependencies": { "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.12", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA=="], - - "@vitest/runner": ["@vitest/runner@4.1.12", "", { "dependencies": { "@vitest/utils": "4.1.12", "pathe": "^2.0.3" } }, "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.12", "", { "dependencies": { "@vitest/pretty-format": "4.1.12", "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw=="], - - "@vitest/spy": ["@vitest/spy@4.1.12", "", {}, "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg=="], - - "@vitest/utils": ["@vitest/utils@4.1.12", "", { "dependencies": { "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA=="], - - "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - - "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], - - "cjs-module-lexer": ["cjs-module-lexer@1.2.3", "", {}, "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], - - "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], - - "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=="], - - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - - "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "hono": ["hono@4.13.16", "", {}, "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "miniflare": ["miniflare@5.20260918.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.4", "undici": "7.29.0", "workerd": "1.20260918.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-vyIes7yW/OTzHtz4GhcBCTohZfqk2eQNiIkngZ07VRA5Qu24aNgW/TrFwlPkMLMjWDQ8R4JJBBHR/KX+liSDtg=="], - - "nanoid": ["nanoid@3.3.20", "", { "bin": "./bin/nanoid.cjs" }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], - - "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], - - "oxfmt": ["oxfmt@0.76.0", "", { "dependencies": { "tinypool": "2.2.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.76.0", "@oxfmt/binding-android-arm64": "0.76.0", "@oxfmt/binding-darwin-arm64": "0.76.0", "@oxfmt/binding-darwin-x64": "0.76.0", "@oxfmt/binding-freebsd-x64": "0.76.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.76.0", "@oxfmt/binding-linux-arm-musleabihf": "0.76.0", "@oxfmt/binding-linux-arm64-gnu": "0.76.0", "@oxfmt/binding-linux-arm64-musl": "0.76.0", "@oxfmt/binding-linux-ppc64-gnu": "0.76.0", "@oxfmt/binding-linux-riscv64-gnu": "0.76.0", "@oxfmt/binding-linux-riscv64-musl": "0.76.0", "@oxfmt/binding-linux-s390x-gnu": "0.76.0", "@oxfmt/binding-linux-x64-gnu": "0.76.0", "@oxfmt/binding-linux-x64-musl": "0.76.0", "@oxfmt/binding-openharmony-arm64": "0.76.0", "@oxfmt/binding-win32-arm64-msvc": "0.76.0", "@oxfmt/binding-win32-ia32-msvc": "0.76.0", "@oxfmt/binding-win32-x64-msvc": "0.76.0" }, "peerDependencies": { "@astrojs/compiler-rs": "^0.5.0", "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["@astrojs/compiler-rs", "svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-Yo9RIn9lRWTm/U8obHbXUWc39lCpEFXlEggFoPx01zS18OCFi7mAwMmL05+p1AunuJgSkONffWyBHJzpMMCRuQ=="], - - "oxlint": ["oxlint@1.91.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.91.0", "@oxlint/binding-android-arm64": "1.91.0", "@oxlint/binding-darwin-arm64": "1.91.0", "@oxlint/binding-darwin-x64": "1.91.0", "@oxlint/binding-freebsd-x64": "1.91.0", "@oxlint/binding-linux-arm-gnueabihf": "1.91.0", "@oxlint/binding-linux-arm-musleabihf": "1.91.0", "@oxlint/binding-linux-arm64-gnu": "1.91.0", "@oxlint/binding-linux-arm64-musl": "1.91.0", "@oxlint/binding-linux-ppc64-gnu": "1.91.0", "@oxlint/binding-linux-riscv64-gnu": "1.91.0", "@oxlint/binding-linux-riscv64-musl": "1.91.0", "@oxlint/binding-linux-s390x-gnu": "1.91.0", "@oxlint/binding-linux-x64-gnu": "1.91.0", "@oxlint/binding-linux-x64-musl": "1.91.0", "@oxlint/binding-openharmony-arm64": "1.91.0", "@oxlint/binding-win32-arm64-msvc": "1.91.0", "@oxlint/binding-win32-ia32-msvc": "1.91.0", "@oxlint/binding-win32-x64-msvc": "1.91.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2005", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-vjy/cBZcEFcYh3dbxUoQK+bjc+QaY3ZWo86aJkZ5YAwp/HyiqP7dTdNgbvcIVJafhAH5sZbTqBp4sZcicEZngA=="], - - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], - - "postcss": ["postcss@8.5.32", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g=="], - - "rolldown": ["rolldown@1.2.16", "", { "dependencies": { "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.16", "@rolldown/binding-android-arm64": "1.2.16", "@rolldown/binding-darwin-arm64": "1.2.16", "@rolldown/binding-darwin-x64": "1.2.16", "@rolldown/binding-freebsd-x64": "1.2.16", "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", "@rolldown/binding-linux-arm64-gnu": "1.2.16", "@rolldown/binding-linux-arm64-musl": "1.2.16", "@rolldown/binding-linux-ppc64-gnu": "1.2.16", "@rolldown/binding-linux-s390x-gnu": "1.2.16", "@rolldown/binding-linux-x64-gnu": "1.2.16", "@rolldown/binding-linux-x64-musl": "1.2.16", "@rolldown/binding-openharmony-arm64": "1.2.16", "@rolldown/binding-win32-arm64-msvc": "1.2.16", "@rolldown/binding-win32-x64-msvc": "1.2.16" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw=="], - - "semver": ["semver@7.8.6", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw=="], - - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], - - "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - - "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], - - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - - "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], - - "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], - - "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - - "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], - - "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - - "tinypool": ["tinypool@2.2.0", "", {}, "sha512-jBrmx4lYmaC9k/mgPbylxs7kBUxHtD8256up+HjLaDFfXScKJQyil+SWXSvhAtT7XHo+yTVlpB81PGmHP8oLSQ=="], - - "tinyrainbow": ["tinyrainbow@3.2.0", "", {}, "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A=="], - - "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="], - - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "./bin/tsc", "tsserver": "./bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - - "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], - - "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], - - "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], - - "vite": ["vite@8.3.5", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.30", "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ=="], - - "vitest": ["vitest@4.1.12", "", { "dependencies": { "@vitest/expect": "4.1.12", "@vitest/mocker": "4.1.12", "@vitest/pretty-format": "4.1.12", "@vitest/runner": "4.1.12", "@vitest/snapshot": "4.1.12", "@vitest/spy": "4.1.12", "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.12", "@vitest/browser-preview": "4.1.12", "@vitest/browser-webdriverio": "^4.1.11", "@vitest/coverage-istanbul": "4.1.12", "@vitest/coverage-v8": "4.1.12", "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA=="], - - "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - - "workerd": ["workerd@1.20261023.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20261023.1", "@cloudflare/workerd-darwin-arm64": "1.20261023.1", "@cloudflare/workerd-linux-64": "1.20261023.1", "@cloudflare/workerd-linux-arm64": "1.20261023.1", "@cloudflare/workerd-windows-64": "1.20261023.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NeofJFyaYNpqKpFRJW3ApuonaerPd6/JWlfTtLDYf2A6ygqrDEFnYgEXmc0hhOxqnJsSM+kXJN6VebPojzRrsg=="], - - "wrangler": ["wrangler@4.156.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.2", "miniflare": "5.20261023.3-beta", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20261023.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20261023.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "./bin/cf-wrangler.js", "wrangler": "./bin/wrangler.js", "wrangler2": "./bin/wrangler.js" } }, "sha512-kVLAShuLiNgE3jcrmXcymDf/iPKDPrSxACe5ueugW/33HeNB9E9CkKYchudCeBwgmsnl4k+1zt4NO7vqb/fRuQ=="], - - "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=="], - - "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=="], - - "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], - - "@cloudflare/vitest-plugin/wrangler": ["wrangler@4.135.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.20260918.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260918.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260918.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "./bin/cf-wrangler.js", "wrangler": "./bin/wrangler.js", "wrangler2": "./bin/wrangler.js" } }, "sha512-WrNBQSfIG6YcILJcodYr5ty8vgkzGsV8YX+kfd+uZ5/Bd8cUW0GnUIRKAVfn5kYKqh1m/BPcji9h1d7mE8euFw=="], - - "@cloudflare/vitest-plugin/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - - "miniflare/workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - - "wrangler/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - - "wrangler/miniflare": ["miniflare@5.20261023.3-beta", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.30.1", "workerd": "1.20261023.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-thdXzlktu5GE1o/BlmMAPeJ0Cu6Tu5LpMbRlyfRrpVvMRrhd6p52TMqcITCZZLVzt1UO92VGBkLDhLOpryUAyQ=="], - - "@cloudflare/vitest-plugin/wrangler/@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/vitest-plugin/wrangler/workerd": ["workerd@1.20260918.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260918.1", "@cloudflare/workerd-darwin-arm64": "1.20260918.1", "@cloudflare/workerd-linux-64": "1.20260918.1", "@cloudflare/workerd-linux-arm64": "1.20260918.1", "@cloudflare/workerd-windows-64": "1.20260918.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NsjfQlBNQ0iEniv/STOy4zbp8s5k60PzL1Ter02Eg44arbDhHtf6UOs03E31XDRmmBFxSIkAZuCyld3RKH1wqA=="], - - "miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - - "miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - - "miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - - "miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - - "miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - - "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - - "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - - "wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - - "wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - - "wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - - "wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - - "wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - - "wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - - "wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - - "wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - - "wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - - "wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - - "wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - - "wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - - "wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - - "wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - - "wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - - "wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - - "wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - - "wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - - "wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - - "wrangler/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - - "wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - - "wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - - "wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - - "wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], - - "wrangler/miniflare/sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], - - "wrangler/miniflare/undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="], - - "@cloudflare/vitest-plugin/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260918.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-H5Em6Wd0jjxaloYh2rp+WLBl2eWbkk7nSP1svGt6K1RSv/rNVqmKdpjZPJTBSavOmeYGa88qvtOWwS+33OHTqQ=="], - - "@cloudflare/vitest-plugin/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260918.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CR9JRZEQo93fNgBVF4Df2H2/VYO4n7rxSseSwCVv3bJQEb0huADUSCj2FK4ipxar8ToOEB4wawyw0vJo5U/rQQ=="], - - "@cloudflare/vitest-plugin/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260918.1", "", { "os": "linux", "cpu": "x64" }, "sha512-UQ2nnY3qpXLzQ80frmWO+8HvtqyWaQILe8QYZwpemdjT+sqwCz4Dz+0/WVkFco0v/04kIIqikVeLTY/7gEhmkw=="], - - "@cloudflare/vitest-plugin/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260918.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4rib51MaLNWweUIUxM/Xj558M5QmyZoBSf0ffv+lYah5VTrvwnez3XGxX72pElnBKHROvAPOi5msQ5Ts8JSI0A=="], - - "@cloudflare/vitest-plugin/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260918.1", "", { "os": "win32", "cpu": "x64" }, "sha512-sATrMx5ShYYgmgUGrcTmvsFSJBFuN95NEkX3xwb1qk8w1A6h5N11sea7yN2IeibwPyPmXKjWNjXOno0hZAM71Q=="], - - "wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - - "wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - - "wrangler/miniflare/sharp/@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - - "wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - - "wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - - "wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - - "wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - - "wrangler/miniflare/sharp/@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - - "wrangler/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - - "wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - - "wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], - - "wrangler/miniflare/sharp/@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - - "wrangler/miniflare/sharp/@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - } -} diff --git a/apps/cms/migrations/0001_init.sql b/apps/cms/migrations/0001_init.sql deleted file mode 100644 index 87d6fa8..0000000 --- a/apps/cms/migrations/0001_init.sql +++ /dev/null @@ -1,46 +0,0 @@ --- CMS schema. See docs/designs/cms.md §6.1. Migrations are additive-only. - -CREATE TABLE locales ( - code TEXT PRIMARY KEY, - name TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), - is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)), - fallback TEXT REFERENCES locales (code) ON DELETE SET NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE UNIQUE INDEX locales_single_default ON locales (is_default) WHERE is_default = 1; - -CREATE TABLE translations ( - collection TEXT NOT NULL, - key TEXT NOT NULL, - locale TEXT NOT NULL REFERENCES locales (code) ON DELETE CASCADE, - fields TEXT NOT NULL CHECK (json_valid(fields)), - revision INTEGER NOT NULL DEFAULT 1, - updated_at TEXT NOT NULL, - PRIMARY KEY (collection, key, locale) -); -CREATE INDEX translations_locale ON translations (locale); - -CREATE TABLE revisions ( - id INTEGER PRIMARY KEY, - subject TEXT NOT NULL CHECK (subject IN ('translation', 'locale')), - action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), - collection TEXT, - key TEXT, - locale TEXT, - before TEXT, - after TEXT, - actor TEXT NOT NULL, - created_at TEXT NOT NULL -); -CREATE INDEX revisions_lookup ON revisions (collection, key, locale, id); - -CREATE TABLE meta ( - key TEXT PRIMARY KEY, - value INTEGER NOT NULL -); - -INSERT INTO meta (key, value) VALUES ('content_version', 1); -INSERT INTO locales (code, name, enabled, is_default, fallback, created_at, updated_at) -VALUES ('en', 'English', 1, 1, NULL, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); diff --git a/apps/cms/package.json b/apps/cms/package.json deleted file mode 100644 index cd2fc73..0000000 --- a/apps/cms/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "cms", - "version": "0.1.0", - "private": true, - "license": "MIT", - "type": "module", - "scripts": { - "dev": "wrangler dev --port 8787", - "dev:e2e": "bun scripts/dev-e2e.ts", - "test": "vitest run", - "test:watch": "vitest", - "check": "tsc --noEmit && tsc --noEmit -p scripts", - "lint": "oxlint .", - "format": "oxfmt .", - "format:check": "oxfmt --check .", - "migrate:local": "wrangler d1 migrations apply DB --local", - "migrate:staging": "wrangler d1 migrations apply DB --remote --env staging", - "migrate:alpha": "wrangler d1 migrations apply DB --remote --env alpha", - "seed:local": "bun scripts/seed.ts --local", - "seed:staging": "bun scripts/seed.ts --env staging", - "seed:alpha": "bun scripts/seed.ts --env alpha", - "deploy:staging": "wrangler deploy --env staging", - "deploy:alpha": "wrangler deploy --env alpha" - }, - "dependencies": { - "hono": "4.13.16", - "zod": "4.6.7" - }, - "devDependencies": { - "@cloudflare/vitest-plugin": "1.1.13", - "@cloudflare/workers-types": "5.20261029.1", - "@types/bun": "1.3.14", - "oxfmt": "0.76.0", - "oxlint": "1.91.0", - "typescript": "6.0.3", - "vitest": "4.1.12", - "wrangler": "4.156.0" - }, - "engines": { - "node": ">=22" - }, - "packageManager": "bun@1.3.14" -} diff --git a/apps/cms/scripts/dev-e2e.ts b/apps/cms/scripts/dev-e2e.ts deleted file mode 100644 index be8c508..0000000 --- a/apps/cms/scripts/dev-e2e.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Fresh, seeded local CMS for browser tests: - * bun scripts/dev-e2e.ts [--port 8787] [--fixture path/to/fixture.json] - * - * A fixture adds locales and translations on top of the English seed: - * { "locales": [{ "code": "en-XA", "name": "…", "enabled": true, "fallback": null }], - * "seeds": [{ "locale": "en-XA", "entries": { … } }] } - */ -import { readFileSync, rmSync, writeFileSync } from "node:fs"; -import { parseArgs } from "node:util"; -import { seedSql, type SeedFile } from "../src/seed"; - -const { values } = parseArgs({ - options: { - port: { type: "string", default: "8787" }, - fixture: { type: "string" }, - }, -}); -const persistTo = `.wrangler/e2e-${values.port}`; -const run = (args: string[]) => { - const result = Bun.spawnSync(["bunx", "wrangler", ...args], { - stdout: "inherit", - stderr: "inherit", - }); - if (result.exitCode !== 0) throw new Error(`wrangler ${args[0]} failed`); -}; -const execute = (sql: string) => { - const file = `${persistTo}.sql`; - writeFileSync(file, sql); - run([ - "d1", - "execute", - "DB", - "--local", - "--persist-to", - persistTo, - "--file", - file, - "--yes", - ]); - rmSync(file, { force: true }); -}; -const literal = (value: string | null) => - value === null ? "NULL" : `'${value.replaceAll("'", "''")}'`; - -rmSync(persistTo, { recursive: true, force: true }); -run(["d1", "migrations", "apply", "DB", "--local", "--persist-to", persistTo]); -execute( - seedSql( - JSON.parse(readFileSync("seed/en.json", "utf8")) as SeedFile, - new Date().toISOString(), - ), -); -if (values.fixture) { - const fixture = JSON.parse(readFileSync(values.fixture, "utf8")) as { - locales: { - code: string; - name: string; - enabled: boolean; - fallback?: string | null; - }[]; - seeds: SeedFile[]; - }; - const now = new Date().toISOString(); - execute( - fixture.locales - .map( - (locale) => - `INSERT INTO locales (code, name, enabled, is_default, fallback, created_at, updated_at) VALUES (${literal(locale.code)}, ${literal(locale.name)}, ${locale.enabled ? 1 : 0}, 0, ${literal(locale.fallback ?? null)}, '${now}', '${now}');`, - ) - .join("\n") + "\n", - ); - for (const seed of fixture.seeds) - execute(seedSql(seed, `${now}-${seed.locale}`)); -} -const dev = Bun.spawn( - [ - "bunx", - "wrangler", - "dev", - "--ip", - "127.0.0.1", - "--port", - values.port!, - "--inspector-port", - String(Number(values.port) + 10_000), - "--show-interactive-dev-session=false", - "--persist-to", - persistTo, - ], - { stdout: "inherit", stderr: "inherit" }, -); -for (const signal of ["SIGINT", "SIGTERM"] as const) - process.on(signal, () => dev.kill(signal)); -process.exit(await dev.exited); diff --git a/apps/cms/scripts/seed.ts b/apps/cms/scripts/seed.ts deleted file mode 100644 index 604b327..0000000 --- a/apps/cms/scripts/seed.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Apply the English seed (inserts missing rows only; never overwrites edits). - * - * bun scripts/seed.ts --local [--persist-to DIR] - * bun scripts/seed.ts --env staging|alpha - * bun scripts/seed.ts --local --file extra.json (additional seed file) - */ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { parseArgs } from "node:util"; -import { seedSql, type SeedFile } from "../src/seed"; - -const { values } = parseArgs({ - options: { - local: { type: "boolean", default: false }, - env: { type: "string" }, - "persist-to": { type: "string" }, - file: { type: "string", default: "seed/en.json" }, - }, -}); -if (values.local === !!values.env) - throw new Error("Pass exactly one of --local or --env ."); - -export const applySql = ( - sql: string, - target: { local: boolean; env?: string; persistTo?: string }, -) => { - const dir = mkdtempSync(join(tmpdir(), "cms-seed-")); - const file = join(dir, "seed.sql"); - writeFileSync(file, sql); - try { - const args = [ - "wrangler", - "d1", - "execute", - "DB", - "--file", - file, - "--yes", - ...(target.local - ? [ - "--local", - ...(target.persistTo ? ["--persist-to", target.persistTo] : []), - ] - : ["--remote", "--env", target.env!]), - ]; - const result = Bun.spawnSync(["bunx", ...args], { - stdout: "inherit", - stderr: "inherit", - }); - if (result.exitCode !== 0) - throw new Error(`wrangler exited ${result.exitCode}`); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}; - -if (import.meta.main) { - const seed = JSON.parse(readFileSync(values.file!, "utf8")) as SeedFile; - applySql(seedSql(seed, new Date().toISOString()), { - local: values.local!, - env: values.env, - persistTo: values["persist-to"], - }); - console.log( - `Seeded ${values.file} (${seed.locale}); existing rows untouched.`, - ); -} diff --git a/apps/cms/scripts/tsconfig.json b/apps/cms/scripts/tsconfig.json deleted file mode 100644 index 26d2bb4..0000000 --- a/apps/cms/scripts/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "es2024", - "module": "esnext", - "moduleResolution": "bundler", - "lib": ["es2024"], - "types": ["bun"], - "strict": true, - "noUncheckedIndexedAccess": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["."] -} diff --git a/apps/cms/seed/en.json b/apps/cms/seed/en.json deleted file mode 100644 index c0395d6..0000000 --- a/apps/cms/seed/en.json +++ /dev/null @@ -1,719 +0,0 @@ -{ - "locale": "en", - "entries": { - "body": { - "SUN": { - "name": "Sun", - "insight": "The Sun symbolizes identity, purpose and the wish to express yourself.", - "keywords": ["identity", "purpose"] - }, - "MOON": { - "name": "Moon", - "insight": "The Moon symbolizes emotional needs, familiar habits and what feels comforting.", - "keywords": ["emotions", "comfort"] - }, - "MERCURY": { - "name": "Mercury", - "insight": "Mercury symbolizes learning, reasoning and the exchange of ideas.", - "keywords": ["thought", "communication"] - }, - "VENUS": { - "name": "Venus", - "insight": "Venus symbolizes affection, attraction and what you value or enjoy.", - "keywords": ["connection", "values"] - }, - "MARS": { - "name": "Mars", - "insight": "Mars symbolizes initiative, desire and the way you assert yourself.", - "keywords": ["action", "drive"] - }, - "JUPITER": { - "name": "Jupiter", - "insight": "Jupiter symbolizes growth through exploration, belief and a search for meaning.", - "keywords": ["growth", "meaning"] - }, - "SATURN": { - "name": "Saturn", - "insight": "Saturn symbolizes boundaries, responsibility and what takes time to build.", - "keywords": ["discipline", "structure"] - }, - "URANUS": { - "name": "Uranus", - "insight": "Uranus symbolizes independence, experimentation and breaks with familiar patterns.", - "keywords": ["change", "freedom"] - }, - "NEPTUNE": { - "name": "Neptune", - "insight": "Neptune symbolizes imagination, longing and the blurred line between ideals and illusion.", - "keywords": ["imagination", "ideals"] - }, - "PLUTO": { - "name": "Pluto", - "insight": "Pluto symbolizes power, control and profound processes of change.", - "keywords": ["power", "transformation"] - }, - "CHIRON": { - "name": "Chiron", - "insight": "Chiron symbolizes vulnerability and the understanding that can grow from difficult experiences.", - "keywords": ["vulnerability", "understanding"] - }, - "CERES": { - "name": "Ceres", - "insight": "Ceres symbolizes nurturing, nourishment and the experience of giving or receiving care.", - "keywords": ["care", "nourishment"] - }, - "NORTH_NODE": { - "name": "North Node", - "insight": "The North Node marks the point where the Moon crosses the Sun’s apparent path northward, interpreted here as a symbol of development.", - "keywords": ["lunar node", "development"] - }, - "SOUTH_NODE": { - "name": "South Node", - "insight": "The South Node marks the point where the Moon crosses the Sun’s apparent path southward, interpreted here as a symbol of familiar patterns.", - "keywords": ["lunar node", "familiarity"] - }, - "EARTH": { - "name": "Earth", - "insight": "Earth is shown here as a symbolic point opposite the Sun, rather than a planet observed from Earth.", - "keywords": ["Earth point", "opposite Sun"] - } - }, - "angle": { - "ASCENDANT_SYMBOL": { - "name": "Ascendant", - "insight": "The Ascendant is where the zodiac meets the eastern horizon, symbolizing how you first meet the world.", - "keywords": ["eastern horizon", "approach"] - }, - "MIDHEAVEN": { - "name": "Midheaven", - "insight": "The Midheaven marks the zodiac’s upper meridian crossing and symbolizes vocation and public visibility.", - "keywords": ["meridian", "public life"] - }, - "DESCENDANT": { - "name": "Descendant", - "insight": "The Descendant is where the zodiac meets the western horizon, symbolizing encounters and partnerships.", - "keywords": ["western horizon", "partnership"] - }, - "IMUM_COELI": { - "name": "Imum Coeli", - "insight": "The Imum Coeli is the point opposite the Midheaven, symbolizing home, roots and private foundations.", - "keywords": ["roots", "foundation"] - } - }, - "sign": { - "aries": { - "name": "Aries", - "insight": "Aries symbolizes the impulse to begin, take a risk and act directly.", - "keywords": ["initiative", "courage"] - }, - "taurus": { - "name": "Taurus", - "insight": "Taurus symbolizes steadiness, physical comfort and the patient cultivation of what matters.", - "keywords": ["steadiness", "comfort"] - }, - "gemini": { - "name": "Gemini", - "insight": "Gemini symbolizes curiosity, conversation and the exploration of different perspectives.", - "keywords": ["curiosity", "communication"] - }, - "cancer": { - "name": "Cancer", - "insight": "Cancer symbolizes emotional attachment, protection and the need for belonging.", - "keywords": ["care", "belonging"] - }, - "leo": { - "name": "Leo", - "insight": "Leo symbolizes creative expression, generosity and the wish to be seen.", - "keywords": ["creativity", "expression"] - }, - "virgo": { - "name": "Virgo", - "insight": "Virgo symbolizes careful observation, practical service and the refinement of skills.", - "keywords": ["discernment", "service"] - }, - "libra": { - "name": "Libra", - "insight": "Libra symbolizes cooperation, fairness and the weighing of different needs.", - "keywords": ["balance", "relationship"] - }, - "scorpio": { - "name": "Scorpio", - "insight": "Scorpio symbolizes emotional depth, questions of trust and the process of letting go.", - "keywords": ["depth", "transformation"] - }, - "ophiuchus": { - "name": "Ophiuchus", - "insight": "Ophiuchus is a constellation crossed by the Sun’s apparent path and included in this app’s True Sky preset.", - "keywords": ["constellation", "Sun’s path"] - }, - "sagittarius": { - "name": "Sagittarius", - "insight": "Sagittarius symbolizes exploration, the search for meaning and the testing of beliefs.", - "keywords": ["exploration", "meaning"] - }, - "capricorn": { - "name": "Capricorn", - "insight": "Capricorn symbolizes responsibility, patient ambition and work toward lasting goals.", - "keywords": ["commitment", "structure"] - }, - "aquarius": { - "name": "Aquarius", - "insight": "Aquarius symbolizes independent thought, collective ideals and alternatives to established ways.", - "keywords": ["innovation", "community"] - }, - "pisces": { - "name": "Pisces", - "insight": "Pisces symbolizes imagination, compassion and openness to experiences beyond clear boundaries.", - "keywords": ["imagination", "empathy"] - } - }, - "aspect": { - "Conjunction": { - "name": "Conjunction", - "insight": "A conjunction places two chart factors near the same zodiac position, symbolically combining their themes.", - "keywords": ["union", "focus"] - }, - "Semisextile": { - "name": "Semisextile", - "insight": "A semisextile places two chart factors about 30° apart, interpreted here as a subtle need to accommodate differences.", - "keywords": ["adjustment", "awareness"] - }, - "Sextile": { - "name": "Sextile", - "insight": "A sextile places two chart factors about 60° apart, symbolizing opportunities for cooperation.", - "keywords": ["opportunity", "cooperation"] - }, - "Square": { - "name": "Square", - "insight": "A square places two chart factors about 90° apart, symbolizing friction between themes that call for attention.", - "keywords": ["tension", "action"] - }, - "Trine": { - "name": "Trine", - "insight": "A trine places two chart factors about 120° apart, symbolizing ease and mutual support.", - "keywords": ["flow", "ease"] - }, - "Quincunx": { - "name": "Quincunx", - "insight": "A quincunx places two chart factors about 150° apart, symbolizing themes that can be difficult to reconcile.", - "keywords": ["adjustment", "adaptation"] - }, - "Opposition": { - "name": "Opposition", - "insight": "An opposition places two chart factors about 180° apart, symbolizing contrasting needs held in relationship.", - "keywords": ["contrast", "balance"] - } - }, - "house": { - "1": { - "insight": "The first house represents self-presentation, physical presence and how you enter new situations.", - "keywords": ["presence", "self-presentation"] - }, - "2": { - "insight": "The second house represents personal resources, possessions and what you rely on for security.", - "keywords": ["resources", "security"] - }, - "3": { - "insight": "The third house represents everyday learning, communication, siblings and nearby surroundings.", - "keywords": ["learning", "communication"] - }, - "4": { - "insight": "The fourth house represents home, family roots and the foundations of private life.", - "keywords": ["home", "roots"] - }, - "5": { - "insight": "The fifth house represents creativity, play, romance and relationships with children.", - "keywords": ["creativity", "play"] - }, - "6": { - "insight": "The sixth house represents daily work, practical responsibilities and routines of care.", - "keywords": ["routine", "care"] - }, - "7": { - "insight": "The seventh house represents one-to-one commitments, including partnerships and agreements.", - "keywords": ["partnership", "relationship"] - }, - "8": { - "insight": "The eighth house represents shared resources, obligations and experiences of loss or change.", - "keywords": ["shared resources", "change"] - }, - "9": { - "insight": "The ninth house represents higher learning, travel and the beliefs that shape a wider worldview.", - "keywords": ["exploration", "belief"] - }, - "10": { - "insight": "The tenth house represents vocation, reputation and responsibilities in public life.", - "keywords": ["vocation", "reputation"] - }, - "11": { - "insight": "The eleventh house represents friendships, groups and hopes pursued with others.", - "keywords": ["community", "aspiration"] - }, - "12": { - "insight": "The twelfth house represents solitude, retreat and parts of life kept out of public view.", - "keywords": ["solitude", "retreat"] - } - }, - "ui": { - "app.birth.dateTime": { - "text": "{date} at {time}" - }, - "app.birth.empty": { - "text": "Enter birth details" - }, - "app.empty.caption": { - "text": "Add a date, time and place of birth" - }, - "app.empty.label": { - "text": "Empty chart awaiting birth details" - }, - "app.empty.title": { - "text": "Your sky begins here" - }, - "app.entry.edit": { - "text": "Edit birth details" - }, - "app.entry.headingEmpty": { - "text": "Start with your birth details" - }, - "app.entry.headingWithBirth": { - "text": "Your birth chart" - }, - "app.error.retry": { - "text": "Retry calculation" - }, - "app.error.retryLink": { - "text": "Retry" - }, - "app.meta.title": { - "text": "Your chart | Astrology Engine" - }, - "app.rail.hint": { - "text": "Select a planet, sign, house or angle on the wheel to read about it here." - }, - "app.status.calculating": { - "text": "Calculating your chart…" - }, - "app.status.housesAdjusted": { - "text": "House system adjusted to {houseSystem}." - }, - "app.status.notApplied": { - "text": "Showing {shown} chart; requested settings have not been applied." - }, - "app.status.updating": { - "text": "Showing {shown} chart while updating to {requested}." - }, - "birth.action.done": { - "text": "Done" - }, - "birth.action.retry": { - "text": "Retry" - }, - "birth.chip.dateNeeded": { - "text": "Date needed" - }, - "birth.chip.placeFound": { - "text": "Place found" - }, - "birth.chip.placeNeeded": { - "text": "Birthplace needed" - }, - "birth.chip.timeNeeded": { - "text": "Birth time needed" - }, - "birth.chip.timeProvided": { - "text": "Time provided" - }, - "birth.choice.date": { - "text": "Choose a date" - }, - "birth.choice.offset": { - "text": "Choose the recorded offset" - }, - "birth.choice.place": { - "text": "Choose a birthplace" - }, - "birth.choice.placeOption": { - "text": "{city}, {region}, {country}" - }, - "birth.input.label": { - "text": "Birth details" - }, - "birth.input.placeholder": { - "text": "June 21, 2000 at 1pm in London" - }, - "birth.modal.intro": { - "text": "A date, time and birthplace locate your chart in the sky." - }, - "birth.status.resolving": { - "text": "Resolving birth details…" - }, - "brand.beta": { - "text": "BETA" - }, - "brand.name": { - "text": "Astrology Engine" - }, - "chart.house.name": { - "text": "House {number}" - }, - "chart.reference.faganBradley": { - "text": "Vedic" - }, - "chart.reference.tropical": { - "text": "Tropical" - }, - "chart.reference.trueSky": { - "text": "True Sky" - }, - "details.aspect.title": { - "text": "{first} {aspect} {second}" - }, - "details.body.degree": { - "text": "Degree" - }, - "details.body.house": { - "text": "House" - }, - "details.body.no": { - "text": "No" - }, - "details.body.retrograde": { - "text": "Retrograde" - }, - "details.body.sign": { - "text": "Sign" - }, - "details.body.yes": { - "text": "Yes" - }, - "details.bridging": { - "text": "This placement is in the final degree of its sign, offered here as a symbolic prompt to reflect on transition within that sign." - }, - "details.context": { - "text": "{reference} · {houseSystem} houses" - }, - "details.contextRequested": { - "text": "{reference} · {houseSystem} houses (requested {requested})" - }, - "details.house.cuspSign": { - "text": "Sign at cusp" - }, - "details.house.empty": { - "text": "No bodies in this house" - }, - "details.house.modernRuler": { - "text": "Modern ruler" - }, - "details.house.noRuler": { - "text": "No established ruler" - }, - "details.house.occupants": { - "text": "Occupants" - }, - "details.house.ruler": { - "text": "Ruler" - }, - "details.house.traditionalRuler": { - "text": "Traditional ruler" - }, - "details.house.unavailable": { - "text": "Unavailable" - }, - "details.label.angle": { - "text": "Angle details" - }, - "details.label.aspect": { - "text": "Aspect details" - }, - "details.label.body": { - "text": "Body details" - }, - "details.label.house": { - "text": "House details" - }, - "details.label.sign": { - "text": "Sign details" - }, - "details.shell.close": { - "text": "Close details" - }, - "details.shell.resize": { - "text": "Resize details" - }, - "details.unavailable": { - "text": "Details are unavailable right now." - }, - "error.chart.dataIntegrity": { - "text": "Whoops, chart data couldn’t be verified." - }, - "error.chart.dataUnavailable": { - "text": "Whoops, chart data couldn’t be downloaded." - }, - "error.chart.failed": { - "text": "Whoops, chart calculation failed." - }, - "error.chart.invalidInput": { - "text": "Whoops, these birth details couldn’t be used." - }, - "error.chart.notStarted": { - "text": "Whoops, chart calculation couldn’t start." - }, - "error.chart.noVerifiedData": { - "text": "Whoops, there’s no verified data for this birth date." - }, - "error.chart.outOfRange": { - "text": "Whoops, this birth date is outside the supported range." - }, - "error.chart.timeout": { - "text": "Whoops, chart calculation took too long." - }, - "error.resolver.chooseDate": { - "text": "Choose a valid date interpretation." - }, - "error.resolver.choosePlace": { - "text": "Choose your birthplace." - }, - "error.resolver.dateIncomplete": { - "text": "Enter a complete birth date, including the year." - }, - "error.resolver.emptyInput": { - "text": "Enter your birth details." - }, - "error.resolver.explicitTimezone": { - "text": "An explicit timezone or offset was supplied. Please provide the local birth date and time at the birthplace without a timezone, or clarify the intended local time." - }, - "error.resolver.inputTooLong": { - "text": "Birth details are too long." - }, - "error.resolver.invalidBirth": { - "text": "The birth details could not be validated. Check the date, time and birthplace." - }, - "error.resolver.invalidInput": { - "text": "Please check your birth details." - }, - "error.resolver.outOfCoverage": { - "text": "Birth time is outside sky data coverage." - }, - "error.resolver.placeNotFound": { - "text": "No place found. Add a city and state or country." - }, - "error.resolver.range": { - "text": "The birth details contain a range. Supply one exact date and time." - }, - "error.resolver.temporaryFailure": { - "text": "Unable to resolve birth details. Please try again." - }, - "error.resolver.timeAmbiguous": { - "text": "That local time occurred twice. Choose the recorded UTC offset." - }, - "error.resolver.timeInFuture": { - "text": "Birth time must be in the past." - }, - "error.resolver.timeMissing": { - "text": "Add your birth time to calculate a chart." - }, - "error.resolver.timeSkipped": { - "text": "That local time was skipped when the clocks changed. Correct the birth time." - }, - "explorer.announce.cleared": { - "text": "Selection cleared" - }, - "explorer.announce.opened": { - "text": "Selection opened" - }, - "explorer.announce.pickerReset": { - "text": "Connections updated; open the picker again." - }, - "explorer.announce.unavailable": { - "text": "Selection no longer available" - }, - "explorer.aspects.explore": { - "text": "Explore aspects" - }, - "explorer.aspects.exploreOf": { - "text": "Explore aspects of {body}" - }, - "explorer.list.angles": { - "text": "Choose an angle by name" - }, - "explorer.list.bodies": { - "text": "Choose a body by name" - }, - "explorer.list.houses": { - "text": "Choose a house by number" - }, - "explorer.list.signs": { - "text": "Choose a sign by name" - }, - "explorer.picker.back": { - "text": "Back" - }, - "explorer.picker.close": { - "text": "Close" - }, - "explorer.picker.first": { - "text": "Choose the first body" - }, - "explorer.picker.label": { - "text": "Choose an aspect" - }, - "explorer.picker.second": { - "text": "Choose a connection to {body}" - }, - "landing.footer.brand": { - "text": "ASTROLOGY ENGINE" - }, - "landing.footer.tagline": { - "text": "MADE FOR THE CURIOUS" - }, - "landing.hero.cta": { - "text": "Start exploring" - }, - "landing.hero.description": { - "text": "There’s more to your chart than your Sun sign. Discover the planets, signs and houses that make up your unique sky, one piece at a time." - }, - "landing.hero.eyebrow": { - "text": "A LITTLE CURIOSITY GOES A LONG WAY" - }, - "landing.hero.note": { - "text": "Your chart begins with your birth details." - }, - "landing.hero.title": { - "text": "Explore your\nbirth chart." - }, - "landing.meta.description": { - "text": "Explore the elements of your birth chart, one discovery at a time." - }, - "landing.meta.title": { - "text": "Natal Chart Explorer | Astrology Engine" - }, - "landing.sample.alt": { - "text": "Sample chart showing Aries, Taurus and Gemini, planets, and houses 1 through 3" - }, - "landing.sample.caption": { - "text": "A small glimpse of the bigger picture. This sample isn’t your personal chart." - }, - "landing.sample.index": { - "text": "01 / THE WHEEL" - }, - "landing.sample.label": { - "text": "SAMPLE CHART" - }, - "language.switcher": { - "text": "Language" - }, - "settings.advanced.houses": { - "text": "Advanced settings: {houses} houses" - }, - "settings.help.divisionsConstellation": { - "text": "constellation" - }, - "settings.help.divisionsEqual": { - "text": "equal" - }, - "settings.help.faganBradley": { - "text": "The preset labeled Vedic uses twelve equal signs with the Fagan–Bradley sidereal reference, a Western sidereal convention distinct from those commonly used in Vedic astrology." - }, - "settings.help.label": { - "text": "System help" - }, - "settings.help.summary": { - "text": "Signs: {divisions}. Actual houses: {houses}." - }, - "settings.help.summaryOphiuchusIncluded": { - "text": "Signs: {divisions} · Ophiuchus included. Actual houses: {houses}." - }, - "settings.help.toggle": { - "text": "About this system" - }, - "settings.help.tropical": { - "text": "Tropical astrology divides the zodiac into twelve equal signs, beginning at the March equinox point." - }, - "settings.help.trueSkyConstellations": { - "text": "True Sky uses unequal, constellation-based divisions following the Chimenti convention, including Ophiuchus as a thirteenth sign." - }, - "settings.houses.equal": { - "text": "Equal" - }, - "settings.houses.equalHint": { - "text": "Twelve 30° houses measured from the Ascendant." - }, - "settings.houses.label": { - "text": "House system" - }, - "settings.houses.placidus": { - "text": "Placidus" - }, - "settings.houses.placidusHint": { - "text": "Houses sized by the time it takes each part of the sky to rise." - }, - "settings.houses.wholeSign": { - "text": "Whole Sign" - }, - "settings.houses.wholeSignHint": { - "text": "Each house is one whole sign." - }, - "settings.preset.tropical": { - "text": "Tropical" - }, - "settings.preset.trueSky": { - "text": "True Sky" - }, - "settings.preset.vedic": { - "text": "Vedic" - }, - "settings.presets.label": { - "text": "Zodiac presets" - }, - "wheel.angle.asc": { - "text": "ASC" - }, - "wheel.angle.dsc": { - "text": "DSC" - }, - "wheel.angle.ic": { - "text": "IC" - }, - "wheel.angle.mc": { - "text": "MC" - }, - "wheel.group.label": { - "text": "Grouped bodies: {bodies}" - }, - "wheel.group.labelSelected": { - "text": "Grouped bodies: {bodies}. Selected {body}" - }, - "wheel.groupPicker.close": { - "text": "Close body picker" - }, - "wheel.groupPicker.title": { - "text": "Choose a body" - }, - "wheel.label": { - "text": "Interactive natal chart" - }, - "wheel.mobile.dragHint": { - "text": "Drag sideways to pan" - }, - "wheel.mobile.panLeft": { - "text": "Pan wheel left" - }, - "wheel.mobile.panRight": { - "text": "Pan wheel right" - }, - "wheel.mobile.viewport": { - "text": "Chart viewport" - }, - "wheel.mobile.zoomIn": { - "text": "Zoom half wheel" - }, - "wheel.mobile.zoomOut": { - "text": "Show whole wheel" - }, - "wheel.sign.region": { - "text": "{sign} sign region" - } - } - } -} diff --git a/apps/cms/src/app.ts b/apps/cms/src/app.ts deleted file mode 100644 index 75dedef..0000000 --- a/apps/cms/src/app.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Hono } from "hono"; -import { requireAdmin } from "./auth"; -import type { Catalog } from "./catalog-schema"; -import { cors } from "./cors"; -import { contentVersion } from "./db"; -import type { Bindings } from "./env"; -import { apiError, isApiError, errorResponse } from "./http"; -import { adminRoutes } from "./routes/admin"; -import { publicRoutes } from "./routes/public"; - -export type AppEnv = { Bindings: Bindings }; - -const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); - -export const createApp = (catalog: Catalog) => { - const app = new Hono(); - - app.use(cors); - - app.use(async (c, next) => { - if ( - !READ_METHODS.has(c.req.method) && - !c.req.path.startsWith("/v1/admin/") - ) { - c.header("Allow", "GET, HEAD, OPTIONS"); - throw apiError( - 405, - "method_not_allowed", - "Public endpoints are read-only.", - ); - } - await next(); - }); - - app.get("/health", async (c) => { - const version = await contentVersion(c.env.DB); - c.header("Cache-Control", "no-store"); - return c.json({ ok: true, version }); - }); - - app.use("/v1/admin/*", requireAdmin); - app.route("/", publicRoutes(catalog)); - app.route("/", adminRoutes(catalog)); - - app.notFound((c) => - errorResponse(c, apiError(404, "not_found", "Not found.")), - ); - - app.onError((error, c) => { - if (isApiError(error)) return errorResponse(c, error); - console.error({ event: "cms_error", message: String(error) }); - return errorResponse( - c, - apiError(500, "internal", "Internal server error."), - ); - }); - - return app; -}; diff --git a/apps/cms/src/auth.ts b/apps/cms/src/auth.ts deleted file mode 100644 index 6355489..0000000 --- a/apps/cms/src/auth.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { MiddlewareHandler } from "hono"; -import type { AppEnv } from "./app"; -import { apiError } from "./http"; - -export const MIN_TOKEN_LENGTH = 32; - -const digest = async (value: string) => - new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)), - ); - -/** Constant-time comparison of SHA-256 digests (equal length by construction). */ -const tokenMatches = async (presented: string, expected: string) => - crypto.subtle.timingSafeEqual( - await digest(presented), - await digest(expected), - ); - -/** - * Bearer auth for /v1/admin/*. Fails closed when CMS_ADMIN_TOKEN is unset or - * shorter than 32 characters; CMS_ADMIN_TOKEN_NEXT is accepted for rotation. - */ -export const requireAdmin: MiddlewareHandler = async (c, next) => { - c.header("Cache-Control", "no-store"); - const primary = c.env.CMS_ADMIN_TOKEN ?? ""; - if (primary.length < MIN_TOKEN_LENGTH) - throw apiError(503, "admin_disabled", "Admin access is not configured."); - const accepted = [primary, c.env.CMS_ADMIN_TOKEN_NEXT ?? ""].filter( - (token) => token.length >= MIN_TOKEN_LENGTH, - ); - const match = c.req.header("authorization")?.match(/^Bearer (.+)$/i); - const presented = match?.[1] ?? ""; - let authorised = false; - // Compare against every accepted token so timing doesn't reveal which matched. - for (const token of accepted) - if (await tokenMatches(presented, token)) authorised = true; - if (!authorised) { - c.header("WWW-Authenticate", 'Bearer realm="cms"'); - throw apiError(401, "unauthorized", "A valid admin token is required."); - } - await next(); -}; diff --git a/apps/cms/src/catalog-schema.ts b/apps/cms/src/catalog-schema.ts deleted file mode 100644 index 0ddebb3..0000000 --- a/apps/cms/src/catalog-schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Shape = "named" | "reference" | "text"; -export type CollectionDef = { - shape: Shape; - /** key → placeholder names its text must contain (text shape only). */ - keys: Record; -}; -export type Catalog = Record; - -export const defineCatalog = (catalog: C) => catalog; diff --git a/apps/cms/src/catalog.ts b/apps/cms/src/catalog.ts deleted file mode 100644 index 0fd52bb..0000000 --- a/apps/cms/src/catalog.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * The content catalog: every collection and key the website may request. - * Keys are code-defined (docs/designs/cms.md §5.1). Keep this module - * dependency-free: the website imports its types. - */ -import { defineCatalog } from "./catalog-schema"; - -const keys = (...names: K[]) => - Object.fromEntries(names.map((name) => [name, [] as const])) as Record< - K, - readonly [] - >; - -export const catalog = defineCatalog({ - body: { - shape: "named", - keys: keys( - "SUN", - "EARTH", - "MOON", - "NORTH_NODE", - "SOUTH_NODE", - "MERCURY", - "VENUS", - "MARS", - "JUPITER", - "SATURN", - "URANUS", - "NEPTUNE", - "PLUTO", - "CHIRON", - "CERES", - ), - }, - angle: { - shape: "named", - keys: keys("ASCENDANT_SYMBOL", "MIDHEAVEN", "DESCENDANT", "IMUM_COELI"), - }, - sign: { - shape: "named", - keys: keys( - "aries", - "taurus", - "gemini", - "cancer", - "leo", - "virgo", - "libra", - "scorpio", - "ophiuchus", - "sagittarius", - "capricorn", - "aquarius", - "pisces", - ), - }, - aspect: { - shape: "named", - keys: keys( - "Conjunction", - "Semisextile", - "Sextile", - "Square", - "Trine", - "Quincunx", - "Opposition", - ), - }, - house: { - shape: "reference", - keys: keys("1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"), - }, - ui: { - shape: "text", - keys: { - "app.birth.dateTime": ["date", "time"], - "app.birth.empty": [], - "app.empty.caption": [], - "app.empty.label": [], - "app.empty.title": [], - "app.entry.edit": [], - "app.entry.headingEmpty": [], - "app.entry.headingWithBirth": [], - "app.error.retry": [], - "app.error.retryLink": [], - "app.meta.title": [], - "app.rail.hint": [], - "app.status.calculating": [], - "app.status.housesAdjusted": ["houseSystem"], - "app.status.notApplied": ["shown"], - "app.status.updating": ["shown", "requested"], - "birth.action.done": [], - "birth.action.retry": [], - "birth.chip.dateNeeded": [], - "birth.chip.placeFound": [], - "birth.chip.placeNeeded": [], - "birth.chip.timeNeeded": [], - "birth.chip.timeProvided": [], - "birth.choice.date": [], - "birth.choice.offset": [], - "birth.choice.place": [], - "birth.choice.placeOption": ["city", "region", "country"], - "birth.input.label": [], - "birth.input.placeholder": [], - "birth.modal.intro": [], - "birth.status.resolving": [], - "brand.beta": [], - "brand.name": [], - "chart.house.name": ["number"], - "chart.reference.faganBradley": [], - "chart.reference.tropical": [], - "chart.reference.trueSky": [], - "details.aspect.title": ["first", "aspect", "second"], - "details.body.degree": [], - "details.body.house": [], - "details.body.no": [], - "details.body.retrograde": [], - "details.body.sign": [], - "details.body.yes": [], - "details.bridging": [], - "details.context": ["reference", "houseSystem"], - "details.contextRequested": ["reference", "houseSystem", "requested"], - "details.house.cuspSign": [], - "details.house.empty": [], - "details.house.modernRuler": [], - "details.house.noRuler": [], - "details.house.occupants": [], - "details.house.ruler": [], - "details.house.traditionalRuler": [], - "details.house.unavailable": [], - "details.label.angle": [], - "details.label.aspect": [], - "details.label.body": [], - "details.label.house": [], - "details.label.sign": [], - "details.shell.close": [], - "details.shell.resize": [], - "details.unavailable": [], - "error.chart.dataIntegrity": [], - "error.chart.dataUnavailable": [], - "error.chart.failed": [], - "error.chart.invalidInput": [], - "error.chart.notStarted": [], - "error.chart.noVerifiedData": [], - "error.chart.outOfRange": [], - "error.chart.timeout": [], - "error.resolver.chooseDate": [], - "error.resolver.choosePlace": [], - "error.resolver.dateIncomplete": [], - "error.resolver.emptyInput": [], - "error.resolver.explicitTimezone": [], - "error.resolver.inputTooLong": [], - "error.resolver.invalidBirth": [], - "error.resolver.invalidInput": [], - "error.resolver.outOfCoverage": [], - "error.resolver.placeNotFound": [], - "error.resolver.range": [], - "error.resolver.temporaryFailure": [], - "error.resolver.timeAmbiguous": [], - "error.resolver.timeInFuture": [], - "error.resolver.timeMissing": [], - "error.resolver.timeSkipped": [], - "explorer.announce.cleared": [], - "explorer.announce.opened": [], - "explorer.announce.pickerReset": [], - "explorer.announce.unavailable": [], - "explorer.aspects.explore": [], - "explorer.aspects.exploreOf": ["body"], - "explorer.list.angles": [], - "explorer.list.bodies": [], - "explorer.list.houses": [], - "explorer.list.signs": [], - "explorer.picker.back": [], - "explorer.picker.close": [], - "explorer.picker.first": [], - "explorer.picker.label": [], - "explorer.picker.second": ["body"], - "landing.footer.brand": [], - "landing.footer.tagline": [], - "landing.hero.cta": [], - "landing.hero.description": [], - "landing.hero.eyebrow": [], - "landing.hero.note": [], - "landing.hero.title": [], - "landing.meta.description": [], - "landing.meta.title": [], - "landing.sample.alt": [], - "landing.sample.caption": [], - "landing.sample.index": [], - "landing.sample.label": [], - "language.switcher": [], - "settings.advanced.houses": ["houses"], - "settings.help.divisionsConstellation": [], - "settings.help.divisionsEqual": [], - "settings.help.faganBradley": [], - "settings.help.label": [], - "settings.help.summary": ["divisions", "houses"], - "settings.help.summaryOphiuchusIncluded": ["divisions", "houses"], - "settings.help.toggle": [], - "settings.help.tropical": [], - "settings.help.trueSkyConstellations": [], - "settings.houses.equal": [], - "settings.houses.equalHint": [], - "settings.houses.label": [], - "settings.houses.placidus": [], - "settings.houses.placidusHint": [], - "settings.houses.wholeSign": [], - "settings.houses.wholeSignHint": [], - "settings.preset.tropical": [], - "settings.preset.trueSky": [], - "settings.preset.vedic": [], - "settings.presets.label": [], - "wheel.angle.asc": [], - "wheel.angle.dsc": [], - "wheel.angle.ic": [], - "wheel.angle.mc": [], - "wheel.group.label": ["bodies"], - "wheel.group.labelSelected": ["bodies", "body"], - "wheel.groupPicker.close": [], - "wheel.groupPicker.title": [], - "wheel.label": [], - "wheel.mobile.dragHint": [], - "wheel.mobile.panLeft": [], - "wheel.mobile.panRight": [], - "wheel.mobile.viewport": [], - "wheel.mobile.zoomIn": [], - "wheel.mobile.zoomOut": [], - "wheel.sign.region": ["sign"], - }, - }, -}); - -export type AppCatalog = typeof catalog; diff --git a/apps/cms/src/content.ts b/apps/cms/src/content.ts deleted file mode 100644 index dfb791c..0000000 --- a/apps/cms/src/content.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Catalog } from "./catalog-schema"; -import type { TranslationRecord } from "./db"; - -type Fields = Record; - -/** Pick, for one entry, the first translation along the chain. */ -export const resolveEntry = ( - chain: string[], - rows: Pick[], -) => { - for (const locale of chain) { - const row = rows.find((candidate) => candidate.locale === locale); - if (row) return { locale, fields: JSON.parse(row.fields) as Fields }; - } - return null; -}; - -/** Bundle of all catalog entries resolved along `chain`. */ -export const buildEntries = ( - catalog: Catalog, - chain: string[], - rows: TranslationRecord[], -) => { - const byEntry = new Map(); - for (const row of rows) { - const id = `${row.collection}:${row.key}`; - byEntry.set(id, [...(byEntry.get(id) ?? []), row]); - } - const entries: Record> = {}; - const fallbacks: Record = {}; - for (const [collection, definition] of Object.entries(catalog)) { - entries[collection] = {}; - for (const key of Object.keys(definition.keys)) { - const id = `${collection}:${key}`; - const resolved = resolveEntry(chain, byEntry.get(id) ?? []); - if (!resolved) continue; - entries[collection][key] = resolved.fields; - if (resolved.locale !== chain[0]) fallbacks[id] = resolved.locale; - } - } - return { entries, fallbacks }; -}; diff --git a/apps/cms/src/cors.ts b/apps/cms/src/cors.ts deleted file mode 100644 index 68a72cd..0000000 --- a/apps/cms/src/cors.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { MiddlewareHandler } from "hono"; -import type { AppEnv } from "./app"; - -const allowedOrigins = (value: string | undefined) => - new Set( - (value ?? "") - .split(",") - .map((origin) => origin.trim()) - .filter(Boolean), - ); - -/** - * Allow-list CORS for public routes. Admin routes get no CORS headers, so - * browsers cannot call them cross-origin (docs/designs/cms.md §7.5). - */ -export const cors: MiddlewareHandler = async (c, next) => { - if (c.req.path.startsWith("/v1/admin/")) return next(); - const origin = c.req.header("origin"); - const allowed = - !!origin && allowedOrigins(c.env.CMS_ALLOWED_ORIGINS).has(origin); - - if (c.req.method === "OPTIONS") { - const headers = new Headers({ Vary: "Origin" }); - if (allowed) { - headers.set("Access-Control-Allow-Origin", origin); - headers.set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); - headers.set("Access-Control-Allow-Headers", "If-None-Match"); - headers.set("Access-Control-Max-Age", "86400"); - } - return new Response(null, { status: 204, headers }); - } - - await next(); - c.res.headers.append("Vary", "Origin"); - if (allowed) { - c.res.headers.set("Access-Control-Allow-Origin", origin); - c.res.headers.set("Access-Control-Expose-Headers", "ETag"); - } -}; diff --git a/apps/cms/src/coverage.ts b/apps/cms/src/coverage.ts deleted file mode 100644 index d993563..0000000 --- a/apps/cms/src/coverage.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Catalog } from "./catalog-schema"; -import { resolveEntry } from "./content"; -import type { Locale, TranslationRecord } from "./db"; -import { isCatalogEntry } from "./fields"; -import { effectiveChain } from "./locales"; -import { placeholdersIn, samePlaceholders } from "./placeholders"; - -/** Coverage of the catalog per locale, plus orphaned translations. */ -export const coverageReport = ( - catalog: Catalog, - locales: Locale[], - rows: TranslationRecord[], -) => { - const ids = Object.entries(catalog) - .flatMap(([collection, definition]) => - Object.keys(definition.keys).map((key) => `${collection}:${key}`), - ) - .sort(); - const byEntry = new Map(); - for (const row of rows) { - const id = `${row.collection}:${row.key}`; - byEntry.set(id, [...(byEntry.get(id) ?? []), row]); - } - const mismatched = (row: TranslationRecord) => { - const definition = catalog[row.collection]; - if (definition?.shape !== "text") return false; - const { text } = JSON.parse(row.fields) as { text: string }; - return !samePlaceholders(definition.keys[row.key]!, placeholdersIn(text)); - }; - - return { - default: locales.find((locale) => locale.isDefault)!.code, - total: ids.length, - locales: locales.map((locale) => { - const chain = effectiveChain(locale.code, locales); - const missing: string[] = []; - const fallbacks: Record = {}; - const placeholderMismatches: string[] = []; - for (const id of ids) { - const entryRows = byEntry.get(id) ?? []; - const own = entryRows.find((row) => row.locale === locale.code); - if (own) { - if (mismatched(own)) placeholderMismatches.push(id); - continue; - } - missing.push(id); - fallbacks[id] = resolveEntry(chain, entryRows)?.locale ?? null; - } - return { - code: locale.code, - enabled: locale.enabled, - chain, - translated: ids.length - missing.length, - missing, - fallbacks, - placeholderMismatches, - complete: !missing.length && !placeholderMismatches.length, - }; - }), - orphans: rows - .filter((row) => !isCatalogEntry(catalog, row.collection, row.key)) - .map(({ collection, key, locale }) => ({ collection, key, locale })) - .sort((a, b) => - `${a.collection}:${a.key}:${a.locale}`.localeCompare( - `${b.collection}:${b.key}:${b.locale}`, - ), - ), - }; -}; diff --git a/apps/cms/src/db.ts b/apps/cms/src/db.ts deleted file mode 100644 index c575a86..0000000 --- a/apps/cms/src/db.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { LocaleRow } from "./locales"; - -export const contentVersion = async (db: D1Database) => { - const row = await db - .prepare("SELECT value FROM meta WHERE key = 'content_version'") - .first<{ value: number }>(); - if (!row) throw new Error("content_version missing; migrations not applied"); - return row.value; -}; - -type LocaleRecord = { - code: string; - name: string; - enabled: number; - is_default: number; - fallback: string | null; - created_at: string; - updated_at: string; -}; - -export type Locale = LocaleRow & { createdAt: string; updatedAt: string }; - -const toLocale = (row: LocaleRecord): Locale => ({ - code: row.code, - name: row.name, - enabled: row.enabled === 1, - isDefault: row.is_default === 1, - fallback: row.fallback, - createdAt: row.created_at, - updatedAt: row.updated_at, -}); - -/** All locales, default first, then by code. */ -export const listLocales = async (db: D1Database) => { - const { results } = await db - .prepare("SELECT * FROM locales ORDER BY is_default DESC, code") - .all(); - return results.map(toLocale); -}; - -export type TranslationRecord = { - collection: string; - key: string; - locale: string; - fields: string; - revision: number; - updated_at: string; -}; - -/** Every stored translation for the given locales. */ -export const translationsIn = async (db: D1Database, locales: string[]) => { - if (!locales.length) return []; - const { results } = await db - .prepare( - `SELECT * FROM translations WHERE locale IN (${locales.map(() => "?").join(", ")})`, - ) - .bind(...locales) - .all(); - return results; -}; - -/** Translations of one entry for the given locales. */ -export const entryTranslations = async ( - db: D1Database, - collection: string, - key: string, - locales?: string[], -) => { - const filter = locales?.length - ? ` AND locale IN (${locales.map(() => "?").join(", ")})` - : ""; - const { results } = await db - .prepare( - `SELECT * FROM translations WHERE collection = ? AND key = ?${filter} ORDER BY locale`, - ) - .bind(collection, key, ...(locales ?? [])) - .all(); - return results; -}; - -export const allTranslations = async (db: D1Database) => - ( - await db - .prepare("SELECT * FROM translations ORDER BY collection, key, locale") - .all() - ).results; diff --git a/apps/cms/src/env.ts b/apps/cms/src/env.ts deleted file mode 100644 index 88c4e72..0000000 --- a/apps/cms/src/env.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Bindings = { - DB: D1Database; - CMS_ALLOWED_ORIGINS?: string; - CMS_ADMIN_TOKEN?: string; - CMS_ADMIN_TOKEN_NEXT?: string; -}; diff --git a/apps/cms/src/fields.ts b/apps/cms/src/fields.ts deleted file mode 100644 index b6430c2..0000000 --- a/apps/cms/src/fields.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from "zod"; -import type { Catalog, Shape } from "./catalog-schema"; -import { placeholdersIn, samePlaceholders } from "./placeholders"; - -const trimmed = (max: number) => - z - .string() - .min(1) - .max(max) - .refine((value) => value.trim() === value && value.trim().length > 0, { - message: "Must not be blank or have leading/trailing whitespace", - }); - -const keywords = z.array(trimmed(50)).min(1).max(8); - -export const shapeSchemas = { - named: z.strictObject({ - name: trimmed(100), - insight: trimmed(2000), - keywords, - }), - reference: z.strictObject({ insight: trimmed(2000), keywords }), - text: z.strictObject({ text: trimmed(2000) }), -} satisfies Record; - -export type FieldsResult = - | { ok: true; fields: Record } - | { - ok: false; - code: "unknown_entry" | "validation_failed" | "placeholder_mismatch"; - message: string; - [detail: string]: unknown; - }; - -export const isCatalogEntry = ( - catalog: Catalog, - collection: string, - key: string, -) => - Object.hasOwn(catalog, collection) && - Object.hasOwn(catalog[collection]!.keys, key); - -/** Validate translation fields for a catalog entry. */ -export const validateFields = ( - catalog: Catalog, - collection: string, - key: string, - fields: unknown, -): FieldsResult => { - if (!isCatalogEntry(catalog, collection, key)) - return { - ok: false, - code: "unknown_entry", - message: `'${collection}:${key}' is not in the catalog.`, - }; - const definition = catalog[collection]!; - const parsed = shapeSchemas[definition.shape].safeParse(fields); - if (!parsed.success) - return { - ok: false, - code: "validation_failed", - message: "Fields are invalid.", - issues: parsed.error.issues.map(({ path, message }) => ({ - path: path.join("."), - message, - })), - }; - if ("text" in parsed.data) { - const expected = definition.keys[key]!; - const actual = placeholdersIn(parsed.data.text); - if (!samePlaceholders(expected, actual)) - return { - ok: false, - code: "placeholder_mismatch", - message: "Text must use exactly the declared placeholders.", - expected: [...expected], - actual, - }; - } - return { ok: true, fields: parsed.data }; -}; diff --git a/apps/cms/src/http.ts b/apps/cms/src/http.ts deleted file mode 100644 index 9d34f34..0000000 --- a/apps/cms/src/http.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Context } from "hono"; -import type { ContentfulStatusCode } from "hono/utils/http-status"; - -export type ErrorCode = - | "bad_request" - | "validation_failed" - | "unauthorized" - | "not_found" - | "locale_not_found" - | "unknown_entry" - | "conflict" - | "precondition_failed" - | "placeholder_mismatch" - | "fallback_cycle" - | "method_not_allowed" - | "unsupported_media_type" - | "payload_too_large" - | "admin_disabled" - | "internal"; - -export type ApiError = Error & { - status: ContentfulStatusCode; - code: ErrorCode; - extra: Record; -}; - -export const apiError = ( - status: ContentfulStatusCode, - code: ErrorCode, - message: string, - extra: Record = {}, -): ApiError => - Object.assign(new Error(message), { - name: "CmsApiError", - status, - code, - extra, - }); - -export const isApiError = (error: unknown): error is ApiError => - error instanceof Error && error.name === "CmsApiError"; - -export const errorResponse = (c: Context, error: ApiError) => { - c.header("Cache-Control", "no-store"); - c.header("ETag", undefined); - return c.json( - { error: { code: error.code, message: error.message, ...error.extra } }, - error.status, - ); -}; - -export const PUBLIC_CACHE = "public, max-age=60, stale-while-revalidate=600"; - -export const versionTag = (version: number) => `"v${version}"`; - -/** True when an If-None-Match header matches the given entity tag. */ -export const matchesIfNoneMatch = (header: string | undefined, tag: string) => - !!header && - header - .split(",") - .map((value) => value.trim().replace(/^W\//, "")) - .some((value) => value === tag || value === "*"); - -/** - * Public, cacheable response keyed by content version. Answers 304 before - * running `body` when the client already has this version. - */ -export const publicVersioned = async ( - c: Context, - version: number, - body: () => Promise, -) => { - const tag = versionTag(version); - c.header("Cache-Control", PUBLIC_CACHE); - c.header("ETag", tag); - if (matchesIfNoneMatch(c.req.header("if-none-match"), tag)) - return c.body(null, 304); - return c.json(await body()); -}; diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts deleted file mode 100644 index 63aa405..0000000 --- a/apps/cms/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createApp } from "./app"; -import { catalog } from "./catalog"; - -export default createApp(catalog); diff --git a/apps/cms/src/locales.ts b/apps/cms/src/locales.ts deleted file mode 100644 index d021235..0000000 --- a/apps/cms/src/locales.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Locale rules shared by the CMS and the website. Dependency-free on purpose: - * the website imports this module (see docs/designs/cms.md §5.1, §7.3). - */ -export type LocaleRow = { - code: string; - name: string; - enabled: boolean; - isDefault: boolean; - fallback: string | null; -}; - -const MAX_TAG_LENGTH = 35; - -/** Canonical BCP 47 tag (e.g. `es-mx` → `es-MX`), or null if invalid. */ -export const canonicalLocale = (tag: string): string | null => { - if (!tag || tag.length > MAX_TAG_LENGTH || !/^[A-Za-z0-9-]+$/.test(tag)) - return null; - try { - const [canonical] = Intl.getCanonicalLocales(tag); - if (!canonical) return null; - // ISO 639 two- or three-letter languages only (BCP 47 also allows 5–8). - return /^[a-z]{2,3}$/.test(new Intl.Locale(canonical).language) - ? canonical - : null; - } catch { - return null; - } -}; - -export const languageOf = (code: string) => new Intl.Locale(code).language; - -const byCode = (locales: LocaleRow[]) => - new Map(locales.map((locale) => [locale.code, locale])); - -const defaultCode = (locales: LocaleRow[]) => { - const found = locales.find((locale) => locale.isDefault); - if (!found) throw new Error("No default locale"); - return found.code; -}; - -/** - * Locales consulted for `code`, in order: the locale itself, then its fallback - * pointers (skipping disabled locales, stopping on cycles), then the default. - */ -export const effectiveChain = (code: string, locales: LocaleRow[]) => { - const index = byCode(locales); - const chain = [code]; - const seen = new Set(chain); - let next = index.get(code)?.fallback ?? null; - while (next && !seen.has(next)) { - seen.add(next); - const locale = index.get(next); - if (!locale) break; - if (locale.enabled) chain.push(next); - next = locale.fallback; - } - const fallbackDefault = defaultCode(locales); - if (!chain.includes(fallbackDefault)) chain.push(fallbackDefault); - return chain; -}; - -/** Base language if present, else the alphabetically first sibling, else null. */ -export const defaultFallbackFor = (code: string, locales: LocaleRow[]) => { - const language = languageOf(code); - const others = locales.filter( - (locale) => locale.code !== code && !locale.isDefault, - ); - if (others.some((locale) => locale.code === language) && code !== language) - return language; - const siblings = others - .filter((locale) => languageOf(locale.code) === language) - .map((locale) => locale.code) - .sort(); - return siblings[0] ?? null; -}; - -/** Whether pointing `code` at `fallback` would create a loop. */ -export const wouldCycle = ( - code: string, - fallback: string | null, - locales: LocaleRow[], -) => { - const index = byCode(locales); - const seen = new Set(); - let next = fallback; - while (next && !seen.has(next)) { - if (next === code) return true; - seen.add(next); - next = index.get(next)?.fallback ?? null; - } - return false; -}; - -/** Language tags from an Accept-Language header, best first. */ -export const parseAcceptLanguage = (header: string) => - header - .split(",") - .map((part, position) => { - const [tag = "", ...params] = part.trim().split(";"); - const q = params - .map((param) => param.trim().match(/^q=([01](?:\.\d{0,3})?)$/)) - .find(Boolean); - return { tag: tag.trim(), q: q ? Number(q[1]) : 1, position }; - }) - .filter(({ tag, q }) => tag && tag !== "*" && q > 0 && canonicalLocale(tag)) - .sort((a, b) => b.q - a.q || a.position - b.position) - .map(({ tag }) => tag); - -/** - * First enabled locale matching a requested tag: exact, then base language, - * then a sibling sharing the language (alphabetical). Falls back to default. - */ -export const negotiate = ( - requested: string[], - enabled: string[], - fallbackDefault: string, -) => { - const available = [...enabled].sort(); - for (const tag of requested) { - const canonical = canonicalLocale(tag); - if (!canonical) continue; - if (available.includes(canonical)) return canonical; - const language = languageOf(canonical); - if (available.includes(language)) return language; - const sibling = available.find((code) => languageOf(code) === language); - if (sibling) return sibling; - } - return fallbackDefault; -}; diff --git a/apps/cms/src/placeholders.ts b/apps/cms/src/placeholders.ts deleted file mode 100644 index 2c7d63a..0000000 --- a/apps/cms/src/placeholders.ts +++ /dev/null @@ -1,15 +0,0 @@ -const PLACEHOLDER = /\{([A-Za-z_][A-Za-z0-9_]*)\}/g; - -/** Unique `{name}` placeholders in order of first appearance. */ -export const placeholdersIn = (text: string) => [ - ...new Set(Array.from(text.matchAll(PLACEHOLDER), (match) => match[1]!)), -]; - -export const samePlaceholders = ( - a: readonly string[], - b: readonly string[], -) => { - const left = new Set(a); - const right = new Set(b); - return left.size === right.size && [...left].every((name) => right.has(name)); -}; diff --git a/apps/cms/src/routes/admin.ts b/apps/cms/src/routes/admin.ts deleted file mode 100644 index 9eb9165..0000000 --- a/apps/cms/src/routes/admin.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { Hono } from "hono"; -import { z } from "zod"; -import type { AppEnv } from "../app"; -import type { Catalog } from "../catalog-schema"; -import { - allTranslations, - entryTranslations, - listLocales, - type Locale, - type TranslationRecord, -} from "../db"; -import { coverageReport } from "../coverage"; -import { isCatalogEntry, validateFields } from "../fields"; -import { apiError } from "../http"; -import { - canonicalLocale, - defaultFallbackFor, - effectiveChain, - wouldCycle, -} from "../locales"; -import { actorOf, commitWrite, now, readJson } from "../write"; - -const localeCode = z.string().transform((value, ctx) => { - const canonical = canonicalLocale(value); - if (!canonical) { - ctx.addIssue({ code: "custom", message: "Not a valid language tag" }); - return z.NEVER; - } - return canonical; -}); -const localeName = z - .string() - .min(1) - .max(100) - .refine((value) => value.trim() === value && value.length > 0, { - message: "Must not be blank or padded", - }); - -const createLocale = z.strictObject({ - code: localeCode, - name: localeName, - enabled: z.boolean().optional(), - fallback: localeCode.nullable().optional(), -}); -const updateLocale = z - .strictObject({ - name: localeName.optional(), - enabled: z.boolean().optional(), - fallback: localeCode.nullable().optional(), - }) - .refine((value) => Object.keys(value).length > 0, { - message: "Provide at least one field", - }); - -const parse = (schema: z.ZodType, value: unknown): T => { - const parsed = schema.safeParse(value); - if (!parsed.success) - throw apiError(422, "validation_failed", "Request is invalid.", { - issues: parsed.error.issues.map(({ path, message }) => ({ - path: path.join("."), - message, - })), - }); - return parsed.data; -}; - -const invalid = (message: string): never => { - throw apiError(422, "validation_failed", message); -}; - -const present = (locale: Locale, locales: Locale[]) => ({ - code: locale.code, - name: locale.name, - enabled: locale.enabled, - isDefault: locale.isDefault, - fallback: locale.fallback, - chain: effectiveChain(locale.code, locales), - createdAt: locale.createdAt, - updatedAt: locale.updatedAt, -}); - -const snapshot = (locale: Locale) => ({ - code: locale.code, - name: locale.name, - enabled: locale.enabled, - isDefault: locale.isDefault, - fallback: locale.fallback, -}); - -const findLocale = (locales: Locale[], requested: string) => { - const code = canonicalLocale(requested); - const locale = locales.find((candidate) => candidate.code === code); - if (!locale) - throw apiError( - 404, - "locale_not_found", - `Locale '${requested}' does not exist.`, - ); - return locale; -}; - -const revisionQuery = z.strictObject({ - collection: z.string().min(1).optional(), - key: z.string().min(1).optional(), - locale: localeCode.optional(), - before: z.coerce.number().int().positive().optional(), - limit: z.coerce.number().int().min(1).max(200).default(50), -}); - -type RevisionRecord = { - id: number; - subject: string; - action: string; - collection: string | null; - key: string | null; - locale: string | null; - before: string | null; - after: string | null; - actor: string; - created_at: string; -}; - -/** Parsed If-Match: null (absent), "any" (*), a revision, or "never" (unparseable). */ -const ifMatch = (header: string | undefined) => { - if (header === undefined) return null; - if (header.trim() === "*") return "any" as const; - const match = header.trim().match(/^(?:W\/)?"r(\d+)"$/); - return match ? Number(match[1]) : ("never" as const); -}; - -const preconditionFailed = (): never => { - throw apiError( - 412, - "precondition_failed", - "The translation changed or does not match the precondition.", - ); -}; - -const presentTranslation = (row: TranslationRecord) => ({ - locale: row.locale, - fields: JSON.parse(row.fields) as unknown, - revision: row.revision, - updatedAt: row.updated_at, -}); - -export const adminRoutes = (catalog: Catalog) => { - const routes = new Hono(); - - routes.get("/v1/admin/locales", async (c) => { - const locales = await listLocales(c.env.DB); - return c.json({ - default: locales.find((locale) => locale.isDefault)!.code, - locales: locales.map((locale) => present(locale, locales)), - }); - }); - - routes.post("/v1/admin/locales", async (c) => { - const actor = actorOf(c); - const input = parse(createLocale, await readJson(c)); - const locales = await listLocales(c.env.DB); - if (locales.some((locale) => locale.code === input.code)) - throw apiError(409, "conflict", `Locale '${input.code}' exists.`); - let fallback: string | null; - if (input.fallback === undefined) - fallback = defaultFallbackFor(input.code, locales); - else { - fallback = input.fallback; - if (fallback === input.code) - invalid("A locale cannot fall back to itself."); - if (fallback && !locales.some((locale) => locale.code === fallback)) - invalid(`Fallback '${fallback}' does not exist.`); - } - const timestamp = now(); - const created: Locale = { - code: input.code, - name: input.name, - enabled: input.enabled ?? false, - isDefault: false, - fallback, - createdAt: timestamp, - updatedAt: timestamp, - }; - await commitWrite( - c.env.DB, - [ - c.env.DB.prepare( - `INSERT INTO locales (code, name, enabled, is_default, fallback, created_at, updated_at) - VALUES (?, ?, ?, 0, ?, ?, ?)`, - ).bind( - created.code, - created.name, - created.enabled ? 1 : 0, - created.fallback, - timestamp, - timestamp, - ), - ], - { - subject: "locale", - action: "create", - locale: created.code, - after: snapshot(created), - actor, - }, - ); - c.header("Location", `/v1/admin/locales/${created.code}`); - return c.json(present(created, [...locales, created]), 201); - }); - - routes.patch("/v1/admin/locales/:code", async (c) => { - const actor = actorOf(c); - const locales = await listLocales(c.env.DB); - const current = findLocale(locales, c.req.param("code")); - const input = parse(updateLocale, await readJson(c)); - if (current.isDefault && input.enabled === false) - throw apiError(409, "conflict", "The default locale stays enabled."); - if (current.isDefault && input.fallback) - throw apiError(409, "conflict", "The default locale has no fallback."); - if (input.fallback) { - if (!locales.some((locale) => locale.code === input.fallback)) - invalid(`Fallback '${input.fallback}' does not exist.`); - if (wouldCycle(current.code, input.fallback, locales)) - throw apiError( - 422, - "fallback_cycle", - `Falling back to '${input.fallback}' would create a loop.`, - ); - } - const updated: Locale = { - ...current, - name: input.name ?? current.name, - enabled: input.enabled ?? current.enabled, - fallback: - input.fallback === undefined ? current.fallback : input.fallback, - updatedAt: now(), - }; - await commitWrite( - c.env.DB, - [ - c.env.DB.prepare( - "UPDATE locales SET name = ?, enabled = ?, fallback = ?, updated_at = ? WHERE code = ?", - ).bind( - updated.name, - updated.enabled ? 1 : 0, - updated.fallback, - updated.updatedAt, - updated.code, - ), - ], - { - subject: "locale", - action: "update", - locale: current.code, - before: snapshot(current), - after: snapshot(updated), - actor, - }, - ); - const next = locales.map((locale) => - locale.code === updated.code ? updated : locale, - ); - return c.json(present(updated, next)); - }); - - routes.delete("/v1/admin/locales/:code", async (c) => { - const actor = actorOf(c); - const locales = await listLocales(c.env.DB); - const current = findLocale(locales, c.req.param("code")); - if (current.isDefault) - throw apiError(409, "conflict", "The default locale cannot be deleted."); - const { results } = await c.env.DB.prepare( - "SELECT collection, key, fields FROM translations WHERE locale = ? ORDER BY collection, key", - ) - .bind(current.code) - .all<{ collection: string; key: string; fields: string }>(); - await commitWrite( - c.env.DB, - [ - c.env.DB.prepare("DELETE FROM locales WHERE code = ?").bind( - current.code, - ), - ], - { - subject: "locale", - action: "delete", - locale: current.code, - before: { - ...snapshot(current), - translations: results.map((row) => ({ - collection: row.collection, - key: row.key, - fields: JSON.parse(row.fields), - })), - }, - actor, - }, - ); - return c.body(null, 204); - }); - - routes.get("/v1/admin/catalog", (c) => c.json({ collections: catalog })); - - routes.get("/v1/admin/coverage", async (c) => { - const [locales, rows] = await Promise.all([ - listLocales(c.env.DB), - allTranslations(c.env.DB), - ]); - return c.json(coverageReport(catalog, locales, rows)); - }); - - routes.get("/v1/admin/revisions", async (c) => { - const query = parse(revisionQuery, c.req.query()); - const filters: string[] = []; - const values: (string | number)[] = []; - for (const column of ["collection", "key", "locale"] as const) - if (query[column] !== undefined) { - filters.push(`${column} = ?`); - values.push(query[column]); - } - if (query.before !== undefined) { - filters.push("id < ?"); - values.push(query.before); - } - const where = filters.length ? `WHERE ${filters.join(" AND ")}` : ""; - const { results } = await c.env.DB.prepare( - `SELECT * FROM revisions ${where} ORDER BY id DESC LIMIT ?`, - ) - .bind(...values, query.limit + 1) - .all(); - const page = results.slice(0, query.limit); - return c.json({ - revisions: page.map((row) => ({ - id: row.id, - subject: row.subject, - action: row.action, - collection: row.collection, - key: row.key, - locale: row.locale, - before: row.before === null ? null : JSON.parse(row.before), - after: row.after === null ? null : JSON.parse(row.after), - actor: row.actor, - createdAt: row.created_at, - })), - next: results.length > query.limit ? page.at(-1)!.id : null, - }); - }); - - routes.get("/v1/admin/entries/:collection/:key", async (c) => { - const { collection, key } = c.req.param(); - const inCatalog = isCatalogEntry(catalog, collection, key); - const rows = await entryTranslations(c.env.DB, collection, key); - if (!inCatalog && !rows.length) - throw apiError( - 404, - "unknown_entry", - `'${collection}:${key}' is not in the catalog.`, - ); - const definition = inCatalog ? catalog[collection]! : null; - return c.json({ - collection, - key, - inCatalog, - shape: definition?.shape ?? null, - placeholders: definition ? [...definition.keys[key]!] : [], - translations: rows.map(presentTranslation), - }); - }); - - routes.put( - "/v1/admin/entries/:collection/:key/translations/:locale", - async (c) => { - const { collection, key } = c.req.param(); - const actor = actorOf(c); - const locale = findLocale( - await listLocales(c.env.DB), - c.req.param("locale"), - ); - const validated = validateFields( - catalog, - collection, - key, - await readJson(c), - ); - if (!validated.ok) { - const { code, message, ok: _ok, ...extra } = validated; - throw apiError( - code === "unknown_entry" ? 404 : 422, - code, - message, - extra, - ); - } - const fields = JSON.stringify(validated.fields); - const [existing] = await entryTranslations(c.env.DB, collection, key, [ - locale.code, - ]); - const match = ifMatch(c.req.header("if-match")); - const createOnly = c.req.header("if-none-match")?.trim() === "*"; - if (match === "never") preconditionFailed(); - const timestamp = now(); - const db = c.env.DB; - const where = "WHERE collection = ? AND key = ? AND locale = ?"; - const statement = - match !== null - ? db - .prepare( - `UPDATE translations SET fields = ?, revision = revision + 1, updated_at = ? - ${where}${match === "any" ? "" : " AND revision = ?"} RETURNING revision`, - ) - .bind( - fields, - timestamp, - collection, - key, - locale.code, - ...(match === "any" ? [] : [match]), - ) - : db - .prepare( - `INSERT INTO translations (collection, key, locale, fields, revision, updated_at) - VALUES (?, ?, ?, ?, 1, ?) - ON CONFLICT (collection, key, locale) DO ${ - createOnly - ? "NOTHING" - : "UPDATE SET fields = excluded.fields, revision = translations.revision + 1, updated_at = excluded.updated_at" - } - RETURNING revision`, - ) - .bind(collection, key, locale.code, fields, timestamp); - const created = !existing && match === null; - const { results, version } = await commitWrite( - db, - [statement], - { - subject: "translation", - action: created ? "create" : "update", - collection, - key, - locale: locale.code, - ...(existing ? { before: JSON.parse(existing.fields) } : {}), - after: validated.fields, - actor, - }, - { guard: true }, - ); - if (version === null) preconditionFailed(); - const revision = (results[0]!.results[0] as { revision: number }) - .revision; - c.header("ETag", `"r${revision}"`); - return c.json( - { - collection, - key, - locale: locale.code, - fields: validated.fields, - revision, - updatedAt: timestamp, - }, - revision === 1 ? 201 : 200, - ); - }, - ); - - routes.delete( - "/v1/admin/entries/:collection/:key/translations/:locale", - async (c) => { - const { collection, key } = c.req.param(); - const actor = actorOf(c); - const locales = await listLocales(c.env.DB); - const code = canonicalLocale(c.req.param("locale")); - const locale = locales.find((candidate) => candidate.code === code); - const [existing] = locale - ? await entryTranslations(c.env.DB, collection, key, [locale.code]) - : []; - if (!locale || !existing) - throw apiError(404, "not_found", "Translation does not exist."); - if (locale.isDefault && isCatalogEntry(catalog, collection, key)) - throw apiError( - 409, - "conflict", - "Catalog entries keep their default-locale text; edit it instead.", - ); - const match = ifMatch(c.req.header("if-match")); - if (match === "never") preconditionFailed(); - const revisionFilter = - typeof match === "number" ? " AND revision = ?" : ""; - const { version } = await commitWrite( - c.env.DB, - [ - c.env.DB.prepare( - `DELETE FROM translations WHERE collection = ? AND key = ? AND locale = ?${revisionFilter}`, - ).bind( - collection, - key, - locale.code, - ...(typeof match === "number" ? [match] : []), - ), - ], - { - subject: "translation", - action: "delete", - collection, - key, - locale: locale.code, - before: JSON.parse(existing.fields), - actor, - }, - { guard: true }, - ); - if (version === null) preconditionFailed(); - return c.body(null, 204); - }, - ); - - return routes; -}; diff --git a/apps/cms/src/routes/public.ts b/apps/cms/src/routes/public.ts deleted file mode 100644 index cda8467..0000000 --- a/apps/cms/src/routes/public.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { Hono, type Context } from "hono"; -import type { AppEnv } from "../app"; -import type { Catalog } from "../catalog-schema"; -import { buildEntries, resolveEntry } from "../content"; -import { - contentVersion, - entryTranslations, - listLocales, - translationsIn, -} from "../db"; -import { isCatalogEntry } from "../fields"; -import { apiError, publicVersioned } from "../http"; -import { canonicalLocale, effectiveChain } from "../locales"; - -/** The enabled locale named in the path, with its chain; 404 otherwise. */ -const availableLocale = async (c: Context, requested: string) => { - const code = canonicalLocale(requested); - const locales = await listLocales(c.env.DB); - const locale = locales.find( - (candidate) => candidate.code === code && candidate.enabled, - ); - if (!locale) - throw apiError( - 404, - "locale_not_found", - `Locale '${requested}' is not available.`, - ); - const fallbackDefault = locales.find((candidate) => candidate.isDefault)!; - return { - code: locale.code, - defaultCode: fallbackDefault.code, - chain: effectiveChain(locale.code, locales), - }; -}; - -export const publicRoutes = (catalog: Catalog) => { - const routes = new Hono(); - - routes.get("/v1/locales", async (c) => - publicVersioned(c, await contentVersion(c.env.DB), async () => { - const locales = await listLocales(c.env.DB); - const fallbackDefault = locales.find((locale) => locale.isDefault)!; - return { - default: fallbackDefault.code, - locales: locales - .filter((locale) => locale.enabled) - .map((locale) => ({ - code: locale.code, - name: locale.name, - chain: effectiveChain(locale.code, locales), - })), - }; - }), - ); - - routes.get("/v1/content/:locale", async (c) => { - const locale = await availableLocale(c, c.req.param("locale")); - const version = await contentVersion(c.env.DB); - return publicVersioned(c, version, async () => { - const rows = await translationsIn(c.env.DB, locale.chain); - return { - locale: locale.code, - default: locale.defaultCode, - chain: locale.chain, - version, - ...buildEntries(catalog, locale.chain, rows), - }; - }); - }); - - routes.get("/v1/content/:locale/:collection/:key", async (c) => { - const { collection, key } = c.req.param(); - const locale = await availableLocale(c, c.req.param("locale")); - if (!isCatalogEntry(catalog, collection, key)) - throw apiError( - 404, - "unknown_entry", - `'${collection}:${key}' is not in the catalog.`, - ); - return publicVersioned(c, await contentVersion(c.env.DB), async () => { - const rows = await entryTranslations( - c.env.DB, - collection, - key, - locale.chain, - ); - const resolved = resolveEntry(locale.chain, rows); - if (!resolved) - throw apiError(404, "not_found", "Entry has no translation."); - return { - collection, - key, - locale: resolved.locale, - fallback: resolved.locale !== locale.code, - fields: resolved.fields, - }; - }); - }); - - return routes; -}; diff --git a/apps/cms/src/seed.ts b/apps/cms/src/seed.ts deleted file mode 100644 index 46173e9..0000000 --- a/apps/cms/src/seed.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Seed SQL for the default-locale copy (docs/designs/cms.md §8). Inserts only - * missing rows, so running it on every deploy never overwrites CMS edits; bumps - * the content version only when this run inserted something. - */ -export type SeedFile = { - locale: string; - entries: Record>>; -}; - -const literal = (value: string) => `'${value.replaceAll("'", "''")}'`; - -/** One statement per line (JSON escapes newlines inside values). */ -export const seedSql = (seed: SeedFile, timestamp: string) => { - const lines: string[] = []; - for (const [collection, entries] of Object.entries(seed.entries)) - for (const [key, fields] of Object.entries(entries)) - lines.push( - `INSERT INTO translations (collection, key, locale, fields, revision, updated_at) VALUES (${[ - collection, - key, - seed.locale, - JSON.stringify(fields), - ] - .map(literal) - .join( - ", ", - )}, 1, ${literal(timestamp)}) ON CONFLICT (collection, key, locale) DO NOTHING;`, - ); - lines.push( - `UPDATE meta SET value = value + 1 WHERE key = 'content_version' AND EXISTS (SELECT 1 FROM translations WHERE updated_at = ${literal(timestamp)});`, - ); - return `${lines.join("\n")}\n`; -}; diff --git a/apps/cms/src/write.ts b/apps/cms/src/write.ts deleted file mode 100644 index 855aba3..0000000 --- a/apps/cms/src/write.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { Context } from "hono"; -import type { AppEnv } from "./app"; -import { apiError } from "./http"; - -export const MAX_BODY_BYTES = 64 * 1024; - -/** Parse a JSON request body, enforcing content type and size. */ -export const readJson = async (c: Context): Promise => { - const type = c.req.header("content-type")?.split(";")[0]?.trim(); - if (type !== "application/json") - throw apiError( - 415, - "unsupported_media_type", - "Content-Type must be application/json.", - ); - const declared = Number(c.req.header("content-length") ?? 0); - if (declared > MAX_BODY_BYTES) tooLarge(); - const bytes = await c.req.arrayBuffer(); - if (bytes.byteLength > MAX_BODY_BYTES) tooLarge(); - try { - return JSON.parse(new TextDecoder().decode(bytes)); - } catch { - throw apiError(400, "bad_request", "Body is not valid JSON."); - } -}; - -const tooLarge = (): never => { - throw apiError(413, "payload_too_large", "Body exceeds 64 KB."); -}; - -/** The informational actor from X-CMS-Actor (1–64 printable chars). */ -export const actorOf = (c: Context) => { - const actor = c.req.header("x-cms-actor"); - if (actor === undefined) return "admin"; - // oxlint-disable-next-line no-control-regex - if (!actor.trim() || actor.length > 64 || /[\u0000-\u001f\u007f]/.test(actor)) - throw apiError( - 422, - "validation_failed", - "X-CMS-Actor must be 1–64 printable characters.", - ); - return actor.trim(); -}; - -export const now = () => new Date().toISOString(); - -export type Revision = { - subject: "translation" | "locale"; - action: "create" | "update" | "delete"; - collection?: string | null; - key?: string | null; - locale?: string | null; - before?: unknown; - after?: unknown; - actor: string; -}; - -/** - * Run a write atomically with its revision row and a content version bump. - * - * With `guard`, the revision and version bump only apply if the first - * statement changed a row (SQLite `changes()` within the same batch), so a - * failed precondition leaves no trace. Returns `version: null` in that case. - */ -export const commitWrite = async ( - db: D1Database, - statements: D1PreparedStatement[], - revision: Revision, - { guard = false }: { guard?: boolean } = {}, -) => { - const condition = guard ? " WHERE changes() > 0" : ""; - const results = await db.batch([ - ...statements, - db - .prepare( - `INSERT INTO revisions (subject, action, collection, key, locale, before, after, actor, created_at) - SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?${condition}`, - ) - .bind( - revision.subject, - revision.action, - revision.collection ?? null, - revision.key ?? null, - revision.locale ?? null, - revision.before === undefined ? null : JSON.stringify(revision.before), - revision.after === undefined ? null : JSON.stringify(revision.after), - revision.actor, - now(), - ), - db.prepare( - `UPDATE meta SET value = value + 1 WHERE key = 'content_version'${guard ? " AND changes() > 0" : ""} RETURNING value`, - ), - ]); - const bumped = results.at(-1)!.results[0] as { value: number } | undefined; - const version = bumped?.value ?? null; - if (version !== null) - console.log({ - event: "cms_write", - subject: revision.subject, - action: revision.action, - collection: revision.collection ?? null, - key: revision.key ?? null, - locale: revision.locale ?? null, - actor: revision.actor, - version, - }); - return { results: results.slice(0, statements.length), version }; -}; diff --git a/apps/cms/test/admin-locales.test.ts b/apps/cms/test/admin-locales.test.ts deleted file mode 100644 index a919f55..0000000 --- a/apps/cms/test/admin-locales.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - ADMIN_TOKEN, - addLocale, - addTranslation, - admin, - call, - localeRow, - resetDb, - revisions, - version, -} from "./helpers"; -import { sun } from "./fixtures"; - -beforeEach(resetDb); -afterEach(() => vi.restoreAllMocks()); - -const post = (json: unknown, headers: Record = {}) => - admin("/v1/admin/locales", { method: "POST", json, headers }); -const patch = (code: string, json: unknown) => - admin(`/v1/admin/locales/${code}`, { method: "PATCH", json }); -const remove = (code: string) => - admin(`/v1/admin/locales/${code}`, { method: "DELETE" }); - -describe("GET /v1/admin/locales", () => { - it("lists all locales including disabled ones", async () => { - await addLocale("fr", { enabled: false, name: "Français" }); - const response = await admin("/v1/admin/locales"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - default: "en", - locales: [ - expect.objectContaining({ - code: "en", - name: "English", - enabled: true, - isDefault: true, - fallback: null, - chain: ["en"], - }), - expect.objectContaining({ - code: "fr", - name: "Français", - enabled: false, - isDefault: false, - fallback: null, - chain: ["fr", "en"], - createdAt: expect.any(String), - updatedAt: expect.any(String), - }), - ], - }); - }); -}); - -describe("POST /v1/admin/locales", () => { - it("creates a disabled, canonicalised locale", async () => { - const response = await post({ code: "es-mx", name: "Español (México)" }); - expect(response.status).toBe(201); - expect(response.headers.get("location")).toBe("/v1/admin/locales/es-MX"); - expect(await response.json()).toMatchObject({ - code: "es-MX", - name: "Español (México)", - enabled: false, - isDefault: false, - fallback: null, - chain: ["es-MX", "en"], - }); - expect(await localeRow("es-MX")).toMatchObject({ enabled: 0 }); - }); - - it("defaults the fallback to the base language, then a sibling", async () => { - await addLocale("es-419"); - await post({ code: "es-ES", name: "Español (España)" }); - expect((await localeRow("es-ES"))?.fallback).toBe("es-419"); - await post({ code: "es", name: "Español", enabled: true }); - await post({ code: "es-MX", name: "Español (México)" }); - expect((await localeRow("es-MX"))?.fallback).toBe("es"); - }); - - it("honours an explicit fallback, including null", async () => { - await addLocale("es-419"); - await post({ code: "es-ES", name: "España", fallback: null }); - expect((await localeRow("es-ES"))?.fallback).toBeNull(); - await post({ code: "pt", name: "Português", fallback: "ES-419" }); - expect((await localeRow("pt"))?.fallback).toBe("es-419"); - }); - - it("records a revision, bumps the version and logs the write", async () => { - const log = vi.spyOn(console, "log").mockImplementation(() => {}); - await post({ code: "fr", name: "Français" }, { "x-cms-actor": "amy" }); - expect(await version()).toBe(2); - const [revision] = await revisions(); - expect(revision).toMatchObject({ - subject: "locale", - action: "create", - locale: "fr", - before: null, - actor: "amy", - }); - expect(JSON.parse(revision!.after!)).toMatchObject({ - code: "fr", - name: "Français", - }); - expect(log).toHaveBeenCalledWith( - expect.objectContaining({ - event: "cms_write", - subject: "locale", - action: "create", - locale: "fr", - actor: "amy", - version: 2, - }), - ); - }); - - it.each([ - ["an invalid code", { code: "english", name: "x" }], - ["a missing name", { code: "fr" }], - ["a blank name", { code: "fr", name: " " }], - ["an unknown fallback", { code: "fr", name: "x", fallback: "de" }], - ["itself as fallback", { code: "fr", name: "x", fallback: "fr" }], - ["unknown fields", { code: "fr", name: "x", isDefault: true }], - ])("rejects %s with 422", async (_label, json) => { - const response = await post(json); - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - error: { code: "validation_failed" }, - }); - expect(await version()).toBe(1); - }); - - it("rejects duplicates with 409", async () => { - const response = await post({ code: "EN", name: "English again" }); - expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ - error: { code: "conflict" }, - }); - }); -}); - -describe("PATCH /v1/admin/locales/:code", () => { - beforeEach(async () => { - await addLocale("es-419"); - await addLocale("es-ES", { fallback: "es-419", enabled: false }); - }); - - it("updates name, enabled and fallback with a before/after revision", async () => { - const response = await patch("es-es", { - name: "Español de España", - enabled: true, - fallback: null, - }); - expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ - code: "es-ES", - name: "Español de España", - enabled: true, - fallback: null, - chain: ["es-ES", "en"], - }); - const [revision] = await revisions(); - expect(revision).toMatchObject({ subject: "locale", action: "update" }); - expect(JSON.parse(revision!.before!)).toMatchObject({ enabled: false }); - expect(JSON.parse(revision!.after!)).toMatchObject({ enabled: true }); - expect(await version()).toBe(2); - }); - - it("rejects fallback cycles", async () => { - const response = await patch("es-419", { fallback: "es-ES" }); - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - error: { code: "fallback_cycle" }, - }); - }); - - it("protects the default locale", async () => { - for (const json of [{ enabled: false }, { fallback: "es-419" }]) { - const response = await patch("en", json); - expect(response.status, JSON.stringify(json)).toBe(409); - } - expect((await patch("en", { name: "English (US)" })).status).toBe(200); - }); - - it.each([ - ["an empty body", {}], - ["a bad type", { enabled: "yes" }], - ["an unknown fallback", { fallback: "de" }], - ])("rejects %s with 422", async (_label, json) => { - expect((await patch("es-ES", json)).status).toBe(422); - }); - - it("answers 404 for unknown locales", async () => { - const response = await patch("de", { name: "Deutsch" }); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "locale_not_found" }, - }); - }); -}); - -describe("DELETE /v1/admin/locales/:code", () => { - beforeEach(async () => { - await addLocale("es-419"); - await addLocale("es-ES", { fallback: "es-419" }); - await addTranslation("body", "SUN", "es-419", { ...sun, name: "Sol" }); - }); - - it("deletes the locale and its translations and nulls pointers", async () => { - const response = await remove("es-419"); - expect(response.status).toBe(204); - expect(await localeRow("es-419")).toBeNull(); - expect((await localeRow("es-ES"))?.fallback).toBeNull(); - const left = await env.DB.prepare( - "SELECT count(*) AS n FROM translations WHERE locale = 'es-419'", - ).first<{ n: number }>(); - expect(left?.n).toBe(0); - const [revision] = await revisions(); - expect(revision).toMatchObject({ - subject: "locale", - action: "delete", - after: null, - }); - expect(JSON.parse(revision!.before!)).toMatchObject({ - code: "es-419", - translations: [ - { collection: "body", key: "SUN", fields: { name: "Sol" } }, - ], - }); - expect(await version()).toBe(2); - }); - - it("refuses to delete the default locale", async () => { - expect((await remove("en")).status).toBe(409); - }); - - it("answers 404 for unknown locales", async () => { - expect((await remove("de")).status).toBe(404); - }); -}); - -describe("admin write plumbing", () => { - it("requires a JSON body", async () => { - const response = await call("/v1/admin/locales", { - method: "POST", - headers: { - authorization: `Bearer ${ADMIN_TOKEN}`, - "content-type": "text/plain", - }, - body: '{"code":"fr","name":"x"}', - }); - expect(response.status).toBe(415); - expect(await response.json()).toMatchObject({ - error: { code: "unsupported_media_type" }, - }); - }); - - it("rejects malformed JSON with 400", async () => { - const response = await call("/v1/admin/locales", { - method: "POST", - headers: { - authorization: `Bearer ${ADMIN_TOKEN}`, - "content-type": "application/json", - }, - body: "{", - }); - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ - error: { code: "bad_request" }, - }); - }); - - it("rejects bodies over 64 KB with 413", async () => { - const response = await post({ code: "fr", name: "x".repeat(70_000) }); - expect(response.status).toBe(413); - expect(await response.json()).toMatchObject({ - error: { code: "payload_too_large" }, - }); - }); - - it.each(["", " ", "a".repeat(65), "bad\u0007actor"])( - "rejects actor %j with 422", - async (actor) => { - const response = await post( - { code: "fr", name: "Français" }, - { "x-cms-actor": actor }, - ); - expect(response.status).toBe(422); - }, - ); - - it("defaults the actor to admin", async () => { - await post({ code: "fr", name: "Français" }); - expect((await revisions())[0]?.actor).toBe("admin"); - }); -}); diff --git a/apps/cms/test/admin-reports.test.ts b/apps/cms/test/admin-reports.test.ts deleted file mode 100644 index 12e45ba..0000000 --- a/apps/cms/test/admin-reports.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { addLocale, addTranslation, admin, resetDb } from "./helpers"; -import { sun, testCatalog } from "./fixtures"; - -beforeEach(resetDb); - -describe("GET /v1/admin/catalog", () => { - it("returns the catalog", async () => { - expect(await (await admin("/v1/admin/catalog")).json()).toEqual({ - collections: testCatalog, - }); - }); -}); - -describe("GET /v1/admin/coverage", () => { - beforeEach(async () => { - // Catalog: body SUN/MOON/MARS, house 1/2, ui landing.title/app.previous (7). - for (const key of ["SUN", "MOON", "MARS"]) - await addTranslation("body", key, "en", sun); - for (const key of ["1", "2"]) - await addTranslation("house", key, "en", { - insight: "x", - keywords: ["x"], - }); - await addTranslation("ui", "landing.title", "en", { text: "Hi" }); - await addTranslation("ui", "app.previous", "en", { - text: "{date} in {place}", - }); - await addLocale("es-419", { enabled: false }); - await addLocale("es-ES", { fallback: "es-419" }); - await addTranslation("body", "SUN", "es-419", sun); - await addTranslation("body", "MOON", "es-ES", sun); - await addTranslation("ui", "app.previous", "es-ES", { text: "{date}" }); - await addTranslation("body", "PLUTO", "es-ES", sun); - }); - - it("reports per-locale completeness, fallbacks, mismatches and orphans", async () => { - const response = await admin("/v1/admin/coverage"); - expect(response.status).toBe(200); - const report = await response.json<{ - total: number; - locales: Record[]; - orphans: unknown[]; - }>(); - expect(report.total).toBe(7); - expect(report.locales).toEqual([ - { - code: "en", - enabled: true, - chain: ["en"], - translated: 7, - missing: [], - fallbacks: {}, - placeholderMismatches: [], - complete: true, - }, - { - code: "es-419", - enabled: false, - chain: ["es-419", "en"], - translated: 1, - missing: [ - "body:MARS", - "body:MOON", - "house:1", - "house:2", - "ui:app.previous", - "ui:landing.title", - ], - fallbacks: { - "body:MARS": "en", - "body:MOON": "en", - "house:1": "en", - "house:2": "en", - "ui:app.previous": "en", - "ui:landing.title": "en", - }, - placeholderMismatches: [], - complete: false, - }, - { - code: "es-ES", - enabled: true, - // es-419 is disabled, so it is skipped. - chain: ["es-ES", "en"], - translated: 2, - missing: [ - "body:MARS", - "body:SUN", - "house:1", - "house:2", - "ui:landing.title", - ], - fallbacks: { - "body:MARS": "en", - "body:SUN": "en", - "house:1": "en", - "house:2": "en", - "ui:landing.title": "en", - }, - placeholderMismatches: ["ui:app.previous"], - complete: false, - }, - ]); - expect(report.orphans).toEqual([ - { collection: "body", key: "PLUTO", locale: "es-ES" }, - ]); - }); -}); - -describe("GET /v1/admin/revisions", () => { - const write = (key: string, locale: string, insight: string, actor = "a") => - admin(`/v1/admin/entries/body/${key}/translations/${locale}`, { - method: "PUT", - json: { ...sun, insight }, - headers: { "x-cms-actor": actor }, - }); - - beforeEach(async () => { - await addLocale("fr"); - await write("SUN", "en", "one"); - await write("SUN", "en", "two", "amy"); - await write("MOON", "en", "three"); - await write("SUN", "fr", "four"); - }); - - it("lists newest first with parsed snapshots", async () => { - const { revisions, next } = await ( - await admin("/v1/admin/revisions") - ).json<{ revisions: Record[]; next: number | null }>(); - expect( - revisions.map((r) => (r.after as { insight: string }).insight), - ).toEqual(["four", "three", "two", "one"]); - expect(revisions[2]).toEqual({ - id: expect.any(Number), - subject: "translation", - action: "update", - collection: "body", - key: "SUN", - locale: "en", - before: { ...sun, insight: "one" }, - after: { ...sun, insight: "two" }, - actor: "amy", - createdAt: expect.any(String), - }); - expect(next).toBeNull(); - }); - - it("filters by collection, key and locale", async () => { - const { revisions } = await ( - await admin("/v1/admin/revisions?collection=body&key=SUN&locale=en") - ).json<{ revisions: { after: { insight: string } }[] }>(); - expect(revisions.map((r) => r.after.insight)).toEqual(["two", "one"]); - }); - - it("paginates with limit and before", async () => { - const first = await ( - await admin("/v1/admin/revisions?limit=3") - ).json<{ revisions: { id: number }[]; next: number | null }>(); - expect(first.revisions).toHaveLength(3); - expect(first.next).toBe(first.revisions[2]!.id); - const second = await ( - await admin(`/v1/admin/revisions?limit=3&before=${first.next}`) - ).json<{ revisions: { id: number }[]; next: number | null }>(); - expect(second.revisions).toHaveLength(1); - expect(second.next).toBeNull(); - }); - - it.each(["limit=0", "limit=201", "limit=x", "before=-1", "before=x"])( - "rejects %s", - async (query) => { - const response = await admin(`/v1/admin/revisions?${query}`); - expect(response.status).toBe(422); - }, - ); -}); diff --git a/apps/cms/test/admin-translations.test.ts b/apps/cms/test/admin-translations.test.ts deleted file mode 100644 index 5829eda..0000000 --- a/apps/cms/test/admin-translations.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { - addLocale, - addTranslation, - admin, - resetDb, - revisions, - version, -} from "./helpers"; -import { sun } from "./fixtures"; - -beforeEach(resetDb); - -const path = (collection: string, key: string, locale?: string) => - `/v1/admin/entries/${collection}/${key}${locale ? `/translations/${locale}` : ""}`; -const put = ( - collection: string, - key: string, - locale: string, - json: unknown, - headers: Record = {}, -) => admin(path(collection, key, locale), { method: "PUT", json, headers }); -const del = ( - collection: string, - key: string, - locale: string, - headers: Record = {}, -) => admin(path(collection, key, locale), { method: "DELETE", headers }); -const stored = (collection: string, key: string, locale: string) => - env.DB.prepare( - "SELECT * FROM translations WHERE collection = ? AND key = ? AND locale = ?", - ) - .bind(collection, key, locale) - .first<{ fields: string; revision: number }>(); - -describe("GET /v1/admin/entries/:collection/:key", () => { - it("returns every stored translation with its revision", async () => { - await addLocale("fr", { enabled: false }); - await addTranslation("body", "SUN", "en", sun); - await addTranslation("body", "SUN", "fr", { ...sun, name: "Soleil" }); - const response = await admin(path("body", "SUN")); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - collection: "body", - key: "SUN", - inCatalog: true, - shape: "named", - placeholders: [], - translations: [ - { - locale: "en", - fields: sun, - revision: 1, - updatedAt: "2026-01-01T00:00:00.000Z", - }, - { - locale: "fr", - fields: { ...sun, name: "Soleil" }, - revision: 1, - updatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - }); - - it("shows orphans and declared placeholders", async () => { - await addTranslation("body", "PLUTO", "en", sun); - expect(await (await admin(path("body", "PLUTO"))).json()).toMatchObject({ - inCatalog: false, - translations: [{ locale: "en" }], - }); - expect( - await (await admin(path("ui", "app.previous"))).json(), - ).toMatchObject({ - inCatalog: true, - shape: "text", - placeholders: ["date", "place"], - translations: [], - }); - }); - - it("answers 404 unknown_entry for unknown, unstored entries", async () => { - const response = await admin(path("body", "PLUTO")); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "unknown_entry" }, - }); - }); -}); - -describe("PUT …/translations/:locale", () => { - it("creates a translation (201) with an ETag, revision and version bump", async () => { - const response = await put("body", "SUN", "en", sun, { - "x-cms-actor": "amy", - }); - expect(response.status).toBe(201); - expect(response.headers.get("etag")).toBe('"r1"'); - expect(await response.json()).toMatchObject({ - collection: "body", - key: "SUN", - locale: "en", - fields: sun, - revision: 1, - }); - expect(await version()).toBe(2); - expect(await revisions()).toEqual([ - expect.objectContaining({ - subject: "translation", - action: "create", - collection: "body", - key: "SUN", - locale: "en", - before: null, - after: JSON.stringify(sun), - actor: "amy", - }), - ]); - }); - - it("replaces an existing translation (200) and increments its revision", async () => { - await addTranslation("body", "SUN", "en", sun); - const next = { ...sun, insight: "Updated." }; - const response = await put("body", "SUN", "en", next); - expect(response.status).toBe(200); - expect(response.headers.get("etag")).toBe('"r2"'); - expect(JSON.parse((await stored("body", "SUN", "en"))!.fields)).toEqual( - next, - ); - const [revision] = await revisions(); - expect(revision).toMatchObject({ action: "update" }); - expect(JSON.parse(revision!.before!)).toEqual(sun); - }); - - it("applies If-Match only when the revision matches", async () => { - await addTranslation("body", "SUN", "en", sun); - const next = { ...sun, insight: "Changed." }; - const stale = await put("body", "SUN", "en", next, { "if-match": '"r0"' }); - expect(stale.status).toBe(412); - expect(await stale.json()).toMatchObject({ - error: { code: "precondition_failed" }, - }); - expect(await version()).toBe(1); - expect(await revisions()).toEqual([]); - const fresh = await put("body", "SUN", "en", next, { "if-match": '"r1"' }); - expect(fresh.status).toBe(200); - expect(fresh.headers.get("etag")).toBe('"r2"'); - const any = await put("body", "SUN", "en", sun, { "if-match": "*" }); - expect(any.status).toBe(200); - }); - - it("fails If-Match for a missing translation", async () => { - const response = await put("body", "SUN", "en", sun, { - "if-match": '"r1"', - }); - expect(response.status).toBe(412); - expect(await stored("body", "SUN", "en")).toBeNull(); - }); - - it("creates only with If-None-Match: *", async () => { - expect( - (await put("body", "SUN", "en", sun, { "if-none-match": "*" })).status, - ).toBe(201); - const again = await put("body", "SUN", "en", sun, { "if-none-match": "*" }); - expect(again.status).toBe(412); - expect(await version()).toBe(2); - }); - - it("accepts any existing locale, enabled or not, canonicalising it", async () => { - await addLocale("es-MX", { enabled: false }); - const response = await put("body", "SUN", "es-mx", { ...sun, name: "Sol" }); - expect(response.status).toBe(201); - expect(await response.json()).toMatchObject({ locale: "es-MX" }); - }); - - it("answers 404 locale_not_found for unknown locales", async () => { - const response = await put("body", "SUN", "de", sun); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "locale_not_found" }, - }); - }); - - it("answers 404 unknown_entry outside the catalog", async () => { - const response = await put("body", "PLUTO", "en", sun); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "unknown_entry" }, - }); - }); - - it("answers 422 for invalid fields and placeholder mismatches", async () => { - const invalid = await put("body", "SUN", "en", { name: "Sun" }); - expect(invalid.status).toBe(422); - expect(await invalid.json()).toMatchObject({ - error: { code: "validation_failed", issues: expect.any(Array) }, - }); - const mismatch = await put("ui", "app.previous", "en", { - text: "From {date}.", - }); - expect(mismatch.status).toBe(422); - expect(await mismatch.json()).toMatchObject({ - error: { - code: "placeholder_mismatch", - expected: ["date", "place"], - actual: ["date"], - }, - }); - expect(await version()).toBe(1); - }); -}); - -describe("DELETE …/translations/:locale", () => { - beforeEach(async () => { - await addLocale("fr"); - await addTranslation("body", "SUN", "en", sun); - await addTranslation("body", "SUN", "fr", { ...sun, name: "Soleil" }); - }); - - it("deletes a translation with a revision and version bump", async () => { - const response = await del("body", "SUN", "fr"); - expect(response.status).toBe(204); - expect(await stored("body", "SUN", "fr")).toBeNull(); - expect(await revisions()).toEqual([ - expect.objectContaining({ - subject: "translation", - action: "delete", - locale: "fr", - after: null, - }), - ]); - expect(await version()).toBe(2); - }); - - it("honours If-Match", async () => { - expect( - (await del("body", "SUN", "fr", { "if-match": '"r9"' })).status, - ).toBe(412); - expect( - (await del("body", "SUN", "fr", { "if-match": '"r1"' })).status, - ).toBe(204); - }); - - it("refuses to delete default-locale text of a catalog entry", async () => { - const response = await del("body", "SUN", "en"); - expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ - error: { code: "conflict" }, - }); - }); - - it("deletes orphans in any locale", async () => { - await addTranslation("body", "PLUTO", "en", sun); - expect((await del("body", "PLUTO", "en")).status).toBe(204); - }); - - it("answers 404 for missing translations", async () => { - expect((await del("body", "MOON", "fr")).status).toBe(404); - expect((await del("body", "SUN", "de")).status).toBe(404); - }); -}); diff --git a/apps/cms/test/auth.test.ts b/apps/cms/test/auth.test.ts deleted file mode 100644 index 2a36c30..0000000 --- a/apps/cms/test/auth.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ADMIN_TOKEN, call } from "./helpers"; - -const NEXT = "next-admin-token-abcdefghijklmnopqrstuvwxyz012345"; -const probe = ( - authorization?: string, - overrides: Partial = {}, - method = "GET", -) => - call( - "/v1/admin/does-not-exist", - { method, headers: authorization ? { authorization } : {} }, - overrides, - ); - -describe("admin authentication", () => { - it.each([ - ["no header", undefined], - ["wrong scheme", `Basic ${ADMIN_TOKEN}`], - ["wrong token", "Bearer not-the-token-0123456789abcdefghijkl"], - ["empty token", "Bearer "], - ["token prefix", `Bearer ${ADMIN_TOKEN.slice(0, -1)}`], - ])("rejects %s with 401", async (_label, header) => { - const response = await probe(header); - expect(response.status).toBe(401); - expect(response.headers.get("www-authenticate")).toBe('Bearer realm="cms"'); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(await response.json()).toMatchObject({ - error: { code: "unauthorized" }, - }); - }); - - it("accepts the admin token (case-insensitive scheme)", async () => { - for (const scheme of ["Bearer", "bearer"]) { - const response = await probe(`${scheme} ${ADMIN_TOKEN}`); - expect(response.status).toBe(404); - expect(response.headers.get("cache-control")).toBe("no-store"); - } - }); - - it("lets authorised writes through the read-only guard", async () => { - const response = await probe(`Bearer ${ADMIN_TOKEN}`, {}, "POST"); - expect(response.status).toBe(404); - }); - - it("accepts the rotation token only while it is configured", async () => { - expect( - (await probe(`Bearer ${NEXT}`, { CMS_ADMIN_TOKEN_NEXT: NEXT })).status, - ).toBe(404); - expect((await probe(`Bearer ${NEXT}`)).status).toBe(401); - }); - - it.each([ - ["unset", { CMS_ADMIN_TOKEN: undefined }], - ["empty", { CMS_ADMIN_TOKEN: "" }], - ["too short", { CMS_ADMIN_TOKEN: "short-token" }], - [ - "unset but NEXT set", - { CMS_ADMIN_TOKEN: undefined, CMS_ADMIN_TOKEN_NEXT: NEXT }, - ], - ])("fails closed with 503 when the token is %s", async (_label, env) => { - const response = await probe(`Bearer ${ADMIN_TOKEN}`, env); - expect(response.status).toBe(503); - expect(await response.json()).toMatchObject({ - error: { code: "admin_disabled" }, - }); - }); - - it("ignores a too-short rotation token", async () => { - const response = await probe("Bearer short", { - CMS_ADMIN_TOKEN_NEXT: "short", - }); - expect(response.status).toBe(401); - }); - - it("never echoes token material", async () => { - const response = await probe(`Bearer ${ADMIN_TOKEN}x`); - expect(await response.text()).not.toContain(ADMIN_TOKEN); - }); -}); diff --git a/apps/cms/test/catalog.test.ts b/apps/cms/test/catalog.test.ts deleted file mode 100644 index 28632b4..0000000 --- a/apps/cms/test/catalog.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { catalog } from "../src/catalog"; -import { validateFields } from "../src/fields"; -import type { SeedFile } from "../src/seed"; -import seedJson from "../seed/en.json"; - -const seed = seedJson as SeedFile; -const ids = (entries: Record>) => - Object.entries(entries) - .flatMap(([collection, keys]) => - Object.keys(keys).map((key) => `${collection}:${key}`), - ) - .sort(); - -describe("catalog", () => { - it("uses valid keys and placeholder names", () => { - for (const [collection, definition] of Object.entries(catalog)) { - expect(collection).toMatch(/^[a-z]+$/); - for (const [key, placeholders] of Object.entries(definition.keys)) { - expect(key).toMatch(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/); - if (definition.shape !== "text") expect(placeholders).toEqual([]); - for (const name of placeholders) - expect(name).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/); - } - } - }); - - it("names ui keys .[.] in lower camel case", () => { - for (const key of Object.keys(catalog.ui.keys)) - expect(key).toMatch(/^[a-z][A-Za-z0-9]*(\.[a-z0-9][A-Za-z0-9]*){1,4}$/); - }); -}); - -describe("seed/en.json", () => { - it("is English", () => { - expect(seed.locale).toBe("en"); - }); - - it("covers exactly the catalog", () => { - const catalogIds = ids( - Object.fromEntries( - Object.entries(catalog).map(([name, def]) => [name, def.keys]), - ), - ); - expect(ids(seed.entries)).toEqual(catalogIds); - }); - - it("passes field and placeholder validation", () => { - for (const [collection, entries] of Object.entries(seed.entries)) - for (const [key, fields] of Object.entries(entries)) { - const result = validateFields(catalog, collection, key, fields); - expect(result, `${collection}:${key}`).toMatchObject({ ok: true }); - } - }); -}); diff --git a/apps/cms/test/cors.test.ts b/apps/cms/test/cors.test.ts deleted file mode 100644 index 7d077f9..0000000 --- a/apps/cms/test/cors.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { ADMIN_TOKEN, call, resetDb } from "./helpers"; - -beforeEach(resetDb); - -const site = "https://staging.example.test"; -const origins = { CMS_ALLOWED_ORIGINS: `${site}, http://localhost:5173` }; -const get = (path: string, origin?: string, method = "GET") => - call(path, { method, headers: origin ? { origin } : {} }, origins); - -describe("CORS", () => { - it("allows configured origins on public routes", async () => { - for (const path of ["/v1/locales", "/v1/content/en", "/health"]) { - const response = await get(path, site); - expect(response.headers.get("access-control-allow-origin"), path).toBe( - site, - ); - expect(response.headers.get("access-control-expose-headers")).toBe( - "ETag", - ); - expect(response.headers.get("vary")).toContain("Origin"); - } - }); - - it("sends no allow-origin to other origins but still varies", async () => { - const response = await get("/v1/locales", "https://evil.test"); - expect(response.headers.get("access-control-allow-origin")).toBeNull(); - expect(response.headers.get("vary")).toContain("Origin"); - }); - - it("answers preflight for allowed origins", async () => { - const response = await call( - "/v1/content/en", - { - method: "OPTIONS", - headers: { - origin: site, - "access-control-request-method": "GET", - "access-control-request-headers": "if-none-match", - }, - }, - origins, - ); - expect(response.status).toBe(204); - expect(response.headers.get("access-control-allow-origin")).toBe(site); - expect(response.headers.get("access-control-allow-methods")).toBe( - "GET, HEAD, OPTIONS", - ); - expect(response.headers.get("access-control-allow-headers")).toBe( - "If-None-Match", - ); - expect(response.headers.get("access-control-max-age")).toBe("86400"); - }); - - it("never emits CORS headers on admin routes", async () => { - for (const method of ["GET", "OPTIONS"]) { - const response = await call( - "/v1/admin/locales", - { - method, - headers: { - origin: site, - authorization: `Bearer ${ADMIN_TOKEN}`, - "access-control-request-method": "PUT", - }, - }, - origins, - ); - expect( - response.headers.get("access-control-allow-origin"), - method, - ).toBeNull(); - expect(response.headers.get("access-control-allow-methods")).toBeNull(); - } - }); -}); diff --git a/apps/cms/test/env.d.ts b/apps/cms/test/env.d.ts deleted file mode 100644 index a156805..0000000 --- a/apps/cms/test/env.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { D1Migration } from "@cloudflare/vitest-plugin"; -import type { Bindings } from "../src/env"; - -declare global { - namespace Cloudflare { - interface GlobalProps { - mainModule: typeof import("../src/index"); - } - interface Env extends Bindings { - TEST_MIGRATIONS: D1Migration[]; - } - } -} diff --git a/apps/cms/test/fields.test.ts b/apps/cms/test/fields.test.ts deleted file mode 100644 index d4690ea..0000000 --- a/apps/cms/test/fields.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { validateFields } from "../src/fields"; -import { placeholdersIn, samePlaceholders } from "../src/placeholders"; -import { testCatalog } from "./fixtures"; - -const ok = (collection: string, key: string, fields: unknown) => - validateFields(testCatalog, collection, key, fields); - -describe("validateFields", () => { - it("accepts a named reference entry", () => { - expect( - ok("body", "SUN", { - name: "Sun", - insight: "The Sun symbolizes identity.", - keywords: ["identity", "purpose"], - }), - ).toEqual({ - ok: true, - fields: { - name: "Sun", - insight: "The Sun symbolizes identity.", - keywords: ["identity", "purpose"], - }, - }); - }); - - it("accepts a reference entry without name and a text entry", () => { - expect( - ok("house", "1", { insight: "Presence.", keywords: ["presence"] }).ok, - ).toBe(true); - expect(ok("ui", "landing.title", { text: "Explore\nyour chart." }).ok).toBe( - true, - ); - }); - - it("rejects unknown collections and keys", () => { - expect(ok("planet", "SUN", { text: "x" })).toMatchObject({ - ok: false, - code: "unknown_entry", - }); - expect(ok("body", "PLUTO", { text: "x" })).toMatchObject({ - ok: false, - code: "unknown_entry", - }); - }); - - it.each([ - ["missing field", { name: "Sun", keywords: ["a"] }], - ["unknown field", { name: "Sun", insight: "x", keywords: ["a"], extra: 1 }], - ["empty name", { name: "", insight: "x", keywords: ["a"] }], - ["padded name", { name: " Sun", insight: "x", keywords: ["a"] }], - ["blank insight", { name: "Sun", insight: " ", keywords: ["a"] }], - ["no keywords", { name: "Sun", insight: "x", keywords: [] }], - [ - "too many keywords", - { name: "Sun", insight: "x", keywords: Array(9).fill("a") }, - ], - ["empty keyword", { name: "Sun", insight: "x", keywords: [""] }], - ["long keyword", { name: "Sun", insight: "x", keywords: ["k".repeat(51)] }], - ["long name", { name: "n".repeat(101), insight: "x", keywords: ["a"] }], - [ - "long insight", - { name: "Sun", insight: "i".repeat(2001), keywords: ["a"] }, - ], - ["not an object", "Sun"], - ])("rejects %s", (_label, fields) => { - expect(ok("body", "SUN", fields)).toMatchObject({ - ok: false, - code: "validation_failed", - issues: expect.any(Array), - }); - }); - - it("rejects text that changes the declared placeholders", () => { - expect( - ok("ui", "app.previous", { text: "From {date} in {place}." }).ok, - ).toBe(true); - expect(ok("ui", "app.previous", { text: "From {date}." })).toMatchObject({ - ok: false, - code: "placeholder_mismatch", - expected: ["date", "place"], - actual: ["date"], - }); - expect(ok("ui", "landing.title", { text: "Hi {name}" })).toMatchObject({ - ok: false, - code: "placeholder_mismatch", - }); - }); -}); - -describe("placeholders", () => { - it("extracts unique names in order of appearance", () => { - expect(placeholdersIn("{b} and {a} then {b}; not { c } or {1x}")).toEqual([ - "b", - "a", - ]); - }); - it("compares as sets", () => { - expect(samePlaceholders(["a", "b"], ["b", "a"])).toBe(true); - expect(samePlaceholders(["a"], ["a", "b"])).toBe(false); - }); -}); diff --git a/apps/cms/test/fixtures.ts b/apps/cms/test/fixtures.ts deleted file mode 100644 index c367511..0000000 --- a/apps/cms/test/fixtures.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineCatalog } from "../src/catalog-schema"; - -/** Small catalog for route tests; the real one is tested separately. */ -export const testCatalog = defineCatalog({ - body: { - shape: "named", - keys: { SUN: [], MOON: [], MARS: [] }, - }, - house: { shape: "reference", keys: { "1": [], "2": [] } }, - ui: { - shape: "text", - keys: { - "landing.title": [], - "app.previous": ["date", "place"], - }, - }, -}); - -export const sun = { - name: "Sun", - insight: "The Sun symbolizes identity.", - keywords: ["identity", "purpose"], -}; diff --git a/apps/cms/test/health.test.ts b/apps/cms/test/health.test.ts deleted file mode 100644 index 8f87b9d..0000000 --- a/apps/cms/test/health.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { exports } from "cloudflare:workers"; -import { describe, expect, it } from "vitest"; - -describe("GET /health", () => { - it("reports D1 connectivity and the content version", async () => { - const response = await exports.default.fetch("https://cms.test/health"); - expect(response.status).toBe(200); - expect(response.headers.get("cache-control")).toBe("no-store"); - expect(await response.json()).toEqual({ ok: true, version: 1 }); - }); -}); diff --git a/apps/cms/test/helpers.ts b/apps/cms/test/helpers.ts deleted file mode 100644 index 81c01a4..0000000 --- a/apps/cms/test/helpers.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { env } from "cloudflare:workers"; -import { createApp } from "../src/app"; -import { testCatalog } from "./fixtures"; - -export const ADMIN_TOKEN = "test-admin-token-0123456789abcdefghijklmnop"; - -const app = createApp(testCatalog); - -/** Requests against an app built from the small test catalog. */ -export const call = async ( - path: string, - init?: RequestInit, - overrides: Partial = {}, -) => - app.fetch(new Request(`https://cms.test${path}`, init), { - ...env, - ...overrides, - }); - -export const admin = ( - path: string, - init: RequestInit & { json?: unknown } = {}, -) => { - const { json, ...rest } = init; - const headers = new Headers(rest.headers); - headers.set("authorization", `Bearer ${ADMIN_TOKEN}`); - if (json !== undefined) headers.set("content-type", "application/json"); - return call(path, { - ...rest, - headers, - ...(json !== undefined ? { body: JSON.stringify(json) } : {}), - }); -}; - -/** Tests in one file share D1 state; files are isolated from each other. */ -export const resetDb = async () => { - await env.DB.batch([ - env.DB.prepare("DELETE FROM translations"), - env.DB.prepare("DELETE FROM revisions"), - env.DB.prepare("DELETE FROM locales WHERE is_default = 0"), - env.DB.prepare("UPDATE meta SET value = 1 WHERE key = 'content_version'"), - ]); -}; - -export const addLocale = async ( - code: string, - { - enabled = true, - fallback = null, - name = code, - }: { enabled?: boolean; fallback?: string | null; name?: string } = {}, -) => { - await env.DB.prepare( - `INSERT INTO locales (code, name, enabled, is_default, fallback, created_at, updated_at) - VALUES (?, ?, ?, 0, ?, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')`, - ) - .bind(code, name, enabled ? 1 : 0, fallback) - .run(); -}; - -export const addTranslation = async ( - collection: string, - key: string, - locale: string, - fields: unknown, -) => { - await env.DB.prepare( - `INSERT INTO translations (collection, key, locale, fields, revision, updated_at) - VALUES (?, ?, ?, ?, 1, '2026-01-01T00:00:00.000Z')`, - ) - .bind(collection, key, locale, JSON.stringify(fields)) - .run(); -}; - -export const setVersion = async (value: number) => { - await env.DB.prepare( - "UPDATE meta SET value = ? WHERE key = 'content_version'", - ) - .bind(value) - .run(); -}; - -export const version = async () => - (await env.DB.prepare( - "SELECT value FROM meta WHERE key = 'content_version'", - ).first<{ value: number }>())!.value; - -export type RevisionRow = { - id: number; - subject: string; - action: string; - collection: string | null; - key: string | null; - locale: string | null; - before: string | null; - after: string | null; - actor: string; - created_at: string; -}; - -export const revisions = async () => - ( - await env.DB.prepare( - "SELECT * FROM revisions ORDER BY id", - ).all() - ).results; - -export const localeRow = (code: string) => - env.DB.prepare("SELECT * FROM locales WHERE code = ?").bind(code).first<{ - code: string; - enabled: number; - fallback: string | null; - name: string; - }>(); diff --git a/apps/cms/test/http.test.ts b/apps/cms/test/http.test.ts deleted file mode 100644 index f3dbada..0000000 --- a/apps/cms/test/http.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { exports } from "cloudflare:workers"; -import { describe, expect, it } from "vitest"; - -const call = (path: string, init?: RequestInit) => - exports.default.fetch(`https://cms.test${path}`, init); - -describe("error handling", () => { - it("answers unknown routes with a JSON not_found error", async () => { - const response = await call("/nope"); - expect(response.status).toBe(404); - expect(response.headers.get("content-type")).toMatch(/^application\/json/); - expect(await response.json()).toEqual({ - error: { code: "not_found", message: expect.any(String) }, - }); - }); - - it.each(["POST", "PUT", "PATCH", "DELETE"])( - "rejects %s outside /v1/admin with method_not_allowed", - async (method) => { - for (const path of ["/health", "/v1/locales", "/v1/content/en"]) { - const response = await call(path, { method }); - expect(response.status, `${method} ${path}`).toBe(405); - expect(response.headers.get("allow")).toBe("GET, HEAD, OPTIONS"); - expect(await response.json()).toMatchObject({ - error: { code: "method_not_allowed" }, - }); - } - }, - ); -}); diff --git a/apps/cms/test/locales.test.ts b/apps/cms/test/locales.test.ts deleted file mode 100644 index eff7930..0000000 --- a/apps/cms/test/locales.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - canonicalLocale, - defaultFallbackFor, - effectiveChain, - negotiate, - parseAcceptLanguage, - wouldCycle, - type LocaleRow, -} from "../src/locales"; - -const row = ( - code: string, - extra: Partial> = {}, -): LocaleRow => ({ - code, - name: code, - enabled: true, - isDefault: false, - fallback: null, - ...extra, -}); -const en = row("en", { isDefault: true }); - -describe("canonicalLocale", () => { - it.each([ - ["en", "en"], - ["ES", "es"], - ["es-mx", "es-MX"], - ["es-419", "es-419"], - ["zh-hant-tw", "zh-Hant-TW"], - ])("canonicalises %s to %s", (input, output) => { - expect(canonicalLocale(input)).toBe(output); - }); - - it.each(["", "*", "e", "english", "en_US", "en-", "x".repeat(40)])( - "rejects %j", - (input) => { - expect(canonicalLocale(input)).toBeNull(); - }, - ); -}); - -describe("effectiveChain", () => { - it("is just the default for the default locale", () => { - expect(effectiveChain("en", [en])).toEqual(["en"]); - }); - - it("follows fallback pointers and ends at the default", () => { - const locales = [en, row("es-419"), row("es-ES", { fallback: "es-419" })]; - expect(effectiveChain("es-ES", locales)).toEqual(["es-ES", "es-419", "en"]); - }); - - it("goes straight to the default without a fallback", () => { - expect(effectiveChain("fr", [en, row("fr")])).toEqual(["fr", "en"]); - }); - - it("skips disabled hops but keeps the requested locale", () => { - const locales = [ - en, - row("es", { enabled: false }), - row("es-419", { fallback: "es" }), - row("es-MX", { fallback: "es-419", enabled: false }), - ]; - expect(effectiveChain("es-MX", locales)).toEqual(["es-MX", "es-419", "en"]); - }); - - it("terminates on cycles", () => { - const locales = [ - en, - row("a", { fallback: "b" }), - row("b", { fallback: "a" }), - ]; - expect(effectiveChain("a", locales)).toEqual(["a", "b", "en"]); - }); -}); - -describe("defaultFallbackFor", () => { - it("prefers the base language", () => { - expect(defaultFallbackFor("es-MX", [en, row("es"), row("es-419")])).toBe( - "es", - ); - }); - - it("uses the alphabetically first sibling without a base language", () => { - expect(defaultFallbackFor("es-ES", [en, row("es-MX"), row("es-419")])).toBe( - "es-419", - ); - }); - - it("is null without relatives", () => { - expect(defaultFallbackFor("fr", [en, row("es")])).toBeNull(); - expect(defaultFallbackFor("es", [en, row("es")])).toBeNull(); - }); - - it("uses a regional variant for a base language", () => { - expect(defaultFallbackFor("es", [en, row("es-419")])).toBe("es-419"); - }); -}); - -describe("wouldCycle", () => { - const locales = [en, row("a", { fallback: "b" }), row("b"), row("c")]; - it("detects a pointer back to the start", () => { - expect(wouldCycle("b", "a", locales)).toBe(true); - expect(wouldCycle("a", "a", locales)).toBe(true); - }); - it("accepts acyclic pointers", () => { - expect(wouldCycle("c", "a", locales)).toBe(false); - expect(wouldCycle("b", null, locales)).toBe(false); - }); -}); - -describe("parseAcceptLanguage", () => { - it("orders by quality, keeps ties stable and drops q=0 and *", () => { - expect( - parseAcceptLanguage("fr;q=0.5, es-ES, de;q=0, *;q=0.1, en-GB;q=0.9"), - ).toEqual(["es-ES", "en-GB", "fr"]); - }); - it("ignores malformed input", () => { - expect(parseAcceptLanguage("")).toEqual([]); - expect(parseAcceptLanguage(";;;, ,q=1")).toEqual([]); - }); -}); - -describe("negotiate", () => { - const enabled = ["en", "es-419", "pt"]; - it.each([ - [["es-419"], "es-419"], - [["ES-419"], "es-419"], - [["es-ES"], "es-419"], - [["es"], "es-419"], - [["pt-BR"], "pt"], - [["de", "pt"], "pt"], - [["de"], "en"], - [[], "en"], - ])("%j → %s", (requested, expected) => { - expect(negotiate(requested, enabled, "en")).toBe(expected); - }); -}); diff --git a/apps/cms/test/public-content.test.ts b/apps/cms/test/public-content.test.ts deleted file mode 100644 index d81fc04..0000000 --- a/apps/cms/test/public-content.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { - addLocale, - addTranslation, - call, - resetDb, - setVersion, -} from "./helpers"; -import { sun } from "./fixtures"; - -beforeEach(resetDb); - -const title = { text: "Explore your chart." }; - -describe("GET /v1/content/:locale", () => { - it("returns every catalog collection with the translated entries", async () => { - await addTranslation("body", "SUN", "en", sun); - await addTranslation("ui", "landing.title", "en", title); - const response = await call("/v1/content/en"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - locale: "en", - default: "en", - chain: ["en"], - version: 1, - entries: { - body: { SUN: sun }, - house: {}, - ui: { "landing.title": title }, - }, - fallbacks: {}, - }); - }); - - it("resolves each entry along the chain and reports fallbacks", async () => { - await addLocale("es-419"); - await addLocale("es-ES", { fallback: "es-419" }); - await addTranslation("body", "SUN", "en", sun); - await addTranslation("body", "MOON", "en", { ...sun, name: "Moon" }); - await addTranslation("body", "MARS", "en", { ...sun, name: "Mars" }); - await addTranslation("body", "SUN", "es-419", { ...sun, name: "Sol" }); - await addTranslation("body", "MOON", "es-ES", { ...sun, name: "Luna" }); - - const body = await ( - await call("/v1/content/es-ES") - ).json<{ - chain: string[]; - entries: { body: Record }; - fallbacks: Record; - }>(); - expect(body.chain).toEqual(["es-ES", "es-419", "en"]); - expect(body.entries.body.SUN?.name).toBe("Sol"); - expect(body.entries.body.MOON?.name).toBe("Luna"); - expect(body.entries.body.MARS?.name).toBe("Mars"); - expect(body.fallbacks).toEqual({ "body:SUN": "es-419", "body:MARS": "en" }); - }); - - it("accepts non-canonical casing", async () => { - await addLocale("es-419"); - const body = await ( - await call("/v1/content/ES-419") - ).json<{ - locale: string; - }>(); - expect(body.locale).toBe("es-419"); - }); - - it("excludes orphaned translations", async () => { - await addTranslation("body", "PLUTO", "en", { ...sun, name: "Pluto" }); - await addTranslation("planet", "SUN", "en", sun); - const body = await ( - await call("/v1/content/en") - ).json<{ - entries: Record>; - }>(); - expect(body.entries.body).toEqual({}); - expect(body.entries).not.toHaveProperty("planet"); - }); - - it.each([ - ["disabled", "fr"], - ["unknown", "de"], - ["invalid", "not_a_locale"], - ])("answers 404 locale_not_found for a %s locale", async (_label, code) => { - await addLocale("fr", { enabled: false }); - const response = await call(`/v1/content/${code}`); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "locale_not_found" }, - }); - }); - - it("is cacheable and revalidates by content version", async () => { - await setVersion(12); - const response = await call("/v1/content/en"); - expect(response.headers.get("cache-control")).toBe( - "public, max-age=60, stale-while-revalidate=600", - ); - expect(response.headers.get("etag")).toBe('"v12"'); - expect((await response.json<{ version: number }>()).version).toBe(12); - const revalidated = await call("/v1/content/en", { - headers: { "if-none-match": 'W/"v12"' }, - }); - expect(revalidated.status).toBe(304); - }); -}); - -describe("GET /v1/content/:locale/:collection/:key", () => { - beforeEach(async () => { - await addLocale("es-419"); - await addTranslation("body", "SUN", "en", sun); - await addTranslation("body", "MOON", "es-419", { ...sun, name: "Luna" }); - }); - - it("returns one entry, flagging fallback", async () => { - expect(await (await call("/v1/content/es-419/body/SUN")).json()).toEqual({ - collection: "body", - key: "SUN", - locale: "en", - fallback: true, - fields: sun, - }); - expect( - await (await call("/v1/content/es-419/body/MOON")).json(), - ).toMatchObject({ locale: "es-419", fallback: false }); - }); - - it("is cacheable", async () => { - const response = await call("/v1/content/en/body/SUN"); - expect(response.headers.get("etag")).toBe('"v1"'); - }); - - it("answers 404 unknown_entry outside the catalog", async () => { - const response = await call("/v1/content/en/body/PLUTO"); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "unknown_entry" }, - }); - }); - - it("answers 404 not_found for an untranslated catalog entry", async () => { - const response = await call("/v1/content/en/body/MARS"); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "not_found" }, - }); - }); - - it("answers 404 locale_not_found for unavailable locales", async () => { - const response = await call("/v1/content/fr/body/SUN"); - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { code: "locale_not_found" }, - }); - }); -}); - -describe("error responses", () => { - it("are never cacheable", async () => { - for (const path of [ - "/v1/content/en/body/MARS", - "/v1/content/en/body/PLUTO", - "/v1/content/fr", - ]) { - const response = await call(path); - expect(response.status, path).toBe(404); - expect(response.headers.get("cache-control"), path).toBe("no-store"); - expect(response.headers.get("etag"), path).toBeNull(); - } - }); -}); diff --git a/apps/cms/test/public-locales.test.ts b/apps/cms/test/public-locales.test.ts deleted file mode 100644 index 796b77c..0000000 --- a/apps/cms/test/public-locales.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { addLocale, call, resetDb, setVersion } from "./helpers"; - -beforeEach(resetDb); - -describe("GET /v1/locales", () => { - it("lists the default locale", async () => { - const response = await call("/v1/locales"); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - default: "en", - locales: [{ code: "en", name: "English", chain: ["en"] }], - }); - }); - - it("lists enabled locales only, default first, with effective chains", async () => { - await addLocale("es-419", { name: "Español (Latinoamérica)" }); - await addLocale("es-ES", { name: "Español (España)", fallback: "es-419" }); - await addLocale("fr", { name: "Français", enabled: false }); - const body = await (await call("/v1/locales")).json(); - expect(body).toEqual({ - default: "en", - locales: [ - { code: "en", name: "English", chain: ["en"] }, - { - code: "es-419", - name: "Español (Latinoamérica)", - chain: ["es-419", "en"], - }, - { - code: "es-ES", - name: "Español (España)", - chain: ["es-ES", "es-419", "en"], - }, - ], - }); - }); - - it("is cacheable and revalidates by content version", async () => { - await setVersion(7); - const response = await call("/v1/locales"); - expect(response.headers.get("cache-control")).toBe( - "public, max-age=60, stale-while-revalidate=600", - ); - expect(response.headers.get("etag")).toBe('"v7"'); - - const revalidated = await call("/v1/locales", { - headers: { "if-none-match": '"v7"' }, - }); - expect(revalidated.status).toBe(304); - expect(revalidated.headers.get("etag")).toBe('"v7"'); - - const stale = await call("/v1/locales", { - headers: { "if-none-match": '"v6"' }, - }); - expect(stale.status).toBe(200); - }); -}); diff --git a/apps/cms/test/seed.test.ts b/apps/cms/test/seed.test.ts deleted file mode 100644 index 95b6ba2..0000000 --- a/apps/cms/test/seed.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { env } from "cloudflare:workers"; -import { beforeEach, describe, expect, it } from "vitest"; -import { seedSql, type SeedFile } from "../src/seed"; -import { addTranslation, resetDb, version } from "./helpers"; -import { sun } from "./fixtures"; - -beforeEach(resetDb); - -const seed: SeedFile = { - locale: "en", - entries: { - body: { SUN: sun, MOON: { ...sun, name: 'Moon\'s "light"' } }, - ui: { "landing.title": { text: "Explore your\nbirth chart." } }, - }, -}; - -const run = (sql: string) => - env.DB.batch( - sql - .split("\n") - .filter(Boolean) - .map((line) => env.DB.prepare(line)), - ); -const fields = async (collection: string, key: string) => - JSON.parse( - (await env.DB.prepare( - "SELECT fields FROM translations WHERE collection = ? AND key = ? AND locale = 'en'", - ) - .bind(collection, key) - .first<{ fields: string }>())!.fields, - ); - -describe("seedSql", () => { - it("emits one statement per line", () => { - const sql = seedSql(seed, "2026-11-10T00:00:00.000Z"); - const lines = sql.trim().split("\n"); - expect(lines).toHaveLength(4); - expect(lines.every((line) => line.endsWith(";"))).toBe(true); - }); - - it("inserts every entry, preserving quotes and newlines", async () => { - await run(seedSql(seed, "2026-11-10T00:00:00.000Z")); - expect(await fields("body", "SUN")).toEqual(sun); - expect((await fields("body", "MOON")).name).toBe('Moon\'s "light"'); - expect((await fields("ui", "landing.title")).text).toBe( - "Explore your\nbirth chart.", - ); - expect(await version()).toBe(2); - }); - - it("never overwrites existing rows and is idempotent", async () => { - const edited = { ...sun, insight: "Edited in the CMS." }; - await addTranslation("body", "SUN", "en", edited); - await run(seedSql(seed, "2026-11-10T00:00:00.000Z")); - expect(await fields("body", "SUN")).toEqual(edited); - expect(await version()).toBe(2); - await run(seedSql(seed, "2026-11-10T00:00:01.000Z")); - expect(await version()).toBe(2); - }); -}); diff --git a/apps/cms/test/setup.ts b/apps/cms/test/setup.ts deleted file mode 100644 index 54558a9..0000000 --- a/apps/cms/test/setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { applyD1Migrations } from "cloudflare:test"; -import { env } from "cloudflare:workers"; - -await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/apps/cms/test/worker.test.ts b/apps/cms/test/worker.test.ts deleted file mode 100644 index fdbdfb7..0000000 --- a/apps/cms/test/worker.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { env, exports } from "cloudflare:workers"; -import { describe, expect, it } from "vitest"; -import { catalog } from "../src/catalog"; -import { seedSql, type SeedFile } from "../src/seed"; -import seedJson from "../seed/en.json"; - -// Wiring check of the deployed entry point (real catalog + seed). -describe("worker entry", () => { - it("serves every catalog entry in English after seeding", async () => { - const sql = seedSql(seedJson as SeedFile, "2026-11-10T00:00:00.000Z"); - await env.DB.batch( - sql - .split("\n") - .filter(Boolean) - .map((line) => env.DB.prepare(line)), - ); - const response = await exports.default.fetch( - "https://cms.test/v1/content/en", - ); - expect(response.status).toBe(200); - const bundle = await response.json<{ - entries: Record>; - fallbacks: Record; - }>(); - for (const [collection, definition] of Object.entries(catalog)) - expect(Object.keys(bundle.entries[collection]!).sort()).toEqual( - Object.keys(definition.keys).sort(), - ); - expect(bundle.fallbacks).toEqual({}); - }); -}); diff --git a/apps/cms/tsconfig.json b/apps/cms/tsconfig.json deleted file mode 100644 index 3efeaa5..0000000 --- a/apps/cms/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "target": "es2024", - "module": "esnext", - "moduleResolution": "bundler", - "lib": ["es2024"], - "types": ["@cloudflare/workers-types", "@cloudflare/vitest-plugin/types"], - "strict": true, - "noUncheckedIndexedAccess": true, - "resolveJsonModule": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/apps/cms/vitest.config.ts b/apps/cms/vitest.config.ts deleted file mode 100644 index 20bd727..0000000 --- a/apps/cms/vitest.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-plugin"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - plugins: [ - cloudflareTest(async () => ({ - wrangler: { configPath: "./wrangler.jsonc" }, - miniflare: { - bindings: { - TEST_MIGRATIONS: await readD1Migrations("./migrations"), - CMS_ADMIN_TOKEN: "test-admin-token-0123456789abcdefghijklmnop", - }, - }, - })), - ], - test: { setupFiles: ["./test/setup.ts"] }, -}); diff --git a/apps/cms/wrangler.jsonc b/apps/cms/wrangler.jsonc deleted file mode 100644 index a9d2f34..0000000 --- a/apps/cms/wrangler.jsonc +++ /dev/null @@ -1,66 +0,0 @@ -{ - "$schema": "node_modules/wrangler/config-schema.json", - "name": "astrology-engine-cms-local", - "main": "src/index.ts", - // Newest date supported by the runtime bundled with @cloudflare/vitest-plugin 1.1.13. - "compatibility_date": "2026-09-25", - "workers_dev": false, - "preview_urls": false, - "observability": { - "enabled": true, - "head_sampling_rate": 1, - "redact_query_string": true, - "logs": { - "enabled": true, - "head_sampling_rate": 1, - "invocation_logs": false, - }, - }, - "vars": { - "CMS_ALLOWED_ORIGINS": "http://127.0.0.1:4173,http://localhost:4173,http://localhost:5173", - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "astrology-engine-cms-local", - "database_id": "00000000-0000-0000-0000-000000000000", - "migrations_dir": "migrations", - }, - ], - "env": { - "staging": { - "name": "astrology-engine-cms-staging", - "routes": [ - { "pattern": "cms-staging.astrologyengine.dev", "custom_domain": true }, - ], - "vars": { - "CMS_ALLOWED_ORIGINS": "https://staging.astrologyengine.dev", - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "astrology-engine-cms-staging", - "database_id": "REPLACE_WITH_STAGING_DATABASE_ID", - "migrations_dir": "migrations", - }, - ], - }, - "alpha": { - "name": "astrology-engine-cms-alpha", - "routes": [ - { "pattern": "cms-alpha.astrologyengine.dev", "custom_domain": true }, - ], - "vars": { - "CMS_ALLOWED_ORIGINS": "https://alpha.astrologyengine.dev", - }, - "d1_databases": [ - { - "binding": "DB", - "database_name": "astrology-engine-cms-alpha", - "database_id": "REPLACE_WITH_ALPHA_DATABASE_ID", - "migrations_dir": "migrations", - }, - ], - }, - }, -} diff --git a/bun.lock b/bun.lock index fb72fe1..7dfbd89 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "web", "dependencies": { + "@astrologyengine/cms-contract": "https://github.com/AstrologyEngine/cms/releases/download/v0.1.0/cms-contract-0.1.0.tgz", "@js-temporal/polyfill": "0.5.1", "chrono-node": "2.10.3", "zod": "4.6.7", @@ -30,99 +31,8 @@ }, }, "packages": { - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-z8x4kittQLPsrJWmfoIJyYqs2gNwceGShoUqAJdFRPw3K4kkkKCFHh3Cr4NFRvZb7DPfDS3+JfBjSm3OIyQaJQ=="], - - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zj9fEQhQSKGj8ZTB+X4q4ho4545xWf7qsvJpwREWnBz5BATEFYtStKRgiwYOZvxQ7ORlBEgSj7pmDzuljfV1mQ=="], - - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZy8GpUXaWpic5JPMAvLmaBz95Sx0naZl6hptBPLOMiYmZYiME7jAt6pv8o/tjQqPNWKb+7jKzZhrYx6UqM5MQ=="], - - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U59+a9lVpoRp9enRhTE9GssL3qB1k09CYagRzTfs3dfknopMYV0x0xHZv7Pls3SNMqHlyHR+ufmY76CYafQoLw=="], - - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J1R3YJHPdQJ2RQvzMdTVDWyizhihGiRGGAeEZxtGp629j5pcT5ypSLSiBflopD7o7tmiMD+xlNfhIVmjcHKMVQ=="], - - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x9qTg6zHoqeyzmFVlIjSb9WlhZdoXaayMTeQeDeUEfKA484AgPJz7f9chLjDMONA6wUUX0FJxnVHsYgX5Zv0+g=="], - - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-303gbamm7NDllTtkeaX+8F5Da+KcSBr64mqELQEFg9XgnjfLX3YBCHUcOfgIlVTSybesy43eoeKai8oGr0P6UA=="], - - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qFZqWVkEzuxWMtUkCtHxsnohXHnUMBQr/ZIxF0KmlCSt8de/bc1lbpVgpqjb3QxCADQHG/O1s3JcowcBF2sqdw=="], - - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ROiAid0UH2GN5lMMX2JTgKlOKx1rN+6a1WxIuBsU3j5A3MuB9U5XkREMW76XBbAImnH45mfOCFcSIwb+drcVYg=="], - - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-Oyd/9ObdM2RYkZ7yzF/GyvVdbNgJcRG8BFVwnAVo3ooqzaem4E2p5eTIGB09FecQD+Q5HQEANOTEnIZTO6rBAQ=="], - - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-86DOpmPDR5UzHSOB8y5HlOpYAIBtMx+6L72IyousNx54q1Kz61aykB4ScovFYivZUak+7Ww6tPBUjarluzYKLw=="], - - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-u6omjjFooKfnHLBI+3UtBVInAeuEhoM/RAIw/OiFop2QQgOan5zDfqPR6guMaylt68L60kV+by07Ibp87KDjrw=="], - - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JNk/7HaPDJjy5kih3vq6b71Qif3tHx1ZFACGBzeHeNzyKaIpar4eFGya89GR398K2rwX0eEVmEq1npBPlEkkmg=="], - - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-MDLrOQPXDuPKvPUCvpP3hC6kO72dsYxfpYiR08Nf5DiM75M0d8+D26JwT4uHDf0bcflvlGHcd58Hn2HpxkHUUQ=="], - - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2CcICDrQKqMAOFuzmts/8Ov5iKb+DW/QrW4MFsl2bnRg86uoiUvtqUkHqNSY19Shegc8pBHiXMSEHDjIg94o0A=="], - - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-NjZLJg+tQpMWVSyeWBBKCpib7H/uLq84ZzxCvY5eqHzSaItuzCECGA9MBiZwmW8fnkBIfppg49Ib//LSCGAp8Q=="], - - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0vCMaX/qloc7uaKBDRh+62SBW8EqmhS7c8/al+PxD594ao4lyEWdcoEmVoBgnCNVW07GDpg+9uPV7x6ta9mljw=="], - - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.91.0", "", { "os": "android", "cpu": "arm" }, "sha512-eOqJUUS1MCqIoUUxMIkuwaqIdKaHr1SXYA0CQI95UUrF8jwQylQH33bBmnqhZfvWXKlRB2Ylx/+Sbk/ctEqzZQ=="], - - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.91.0", "", { "os": "android", "cpu": "arm64" }, "sha512-vIJU4lRxK0EhbBt+iR3rkq3pio4I+V5Gshwjei3plNjN+CIq0WxWHzYVI06J9KN8Ar3hyNSFtzJAjVRLZOYpjg=="], - - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.91.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tLy76MurwAOpQt5QVMkJUgexBSWhB7qdvAYb14bc0xntZS6f55aKicbZoxZ/QlmAbnrdlXeD5+IjwY8EZ9RWTg=="], - - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.91.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zPeMmjAqm5vXxbTp/JqYuc9gGSbUuEaeCAkFHB4TCRLTlfJGD//Fkx03YvZHCHZJ7ideLiiLcae+3f2I27qnaA=="], - - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.91.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-psOR9+xGMXn0hj4KY5M2kjSQ1n0TCMdYbPOraoV0iQyl7mw2oBBYGUM2+ma40m1YLVrktTBOta4FjrmcOHDhrg=="], - - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1PNA1j4ETxawvxfz6ueDxWgQgWkXPbv44DWZmC7LR07GqojGFDOwWbosOT6/AYiJr7zMXEhn2dPWMkyFFOFpUQ=="], - - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jcNePVpca37lTbHmiPMnVbPAAW0rH9kAoDjsDv6vESEX9pzazIR1q5SyYuc1rQzdjHtc2VVCDN9jDh6tHcOHyA=="], + "@astrologyengine/cms-contract": ["@astrologyengine/cms-contract@https://github.com/AstrologyEngine/cms/releases/download/v0.1.0/cms-contract-0.1.0.tgz", {}, "sha512-7fRq0KR0DnhOFcYM4lr2Imf1v8kjhiTev97Cghy329wW8vJExjMzurzsWn7PIdVPqoLXA/CbJJaQ7JrWAa2u3A=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.91.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OowlLroUTzKq5OBvbu6iegch8SJ2DivXFCukzPWURwvcJhMj7/P0CUGH5qrDlxT1Kh48dcxqYqrvE8qK6xTLKQ=="], - - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.91.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QF/5Ge8yGpWg5s4awMNHfVKh1WOmk591Qg082i6CHenXYBa+W1E5RwWZXaKUOw4Ko7CCjqg3TfBc0AzhnqblYg=="], - - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-dXlsKlNhzJQX1nageGKnvWe1D7uTng249hQM80CG/YPZ8F/VZHbxVLhImgurdWBOmu86XOemValLjrW6QIOM/Q=="], - - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-tznkj5VrWNRUfymkaLcJetgMLbP9suL5ob/m7j0Fdbnm5u3Li/Ezo/YcBixiuwdL1sg0oGpyTe86It0J6T+Lxg=="], - - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.91.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sNvfLiUYQkVsLNUgIW24AEYTHSd2zZ1vAxXssPdjuQHTtCpRvQihYri6d5z5FY7w/QrcnB5+g9E+/sqmZq8tfA=="], - - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.91.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4hZ4Z9K+OkcTpAHDoUTMg/8MModbM0aocrl9KnGzp9h5iKqjrXraEUZorYoVCT7xoK93CAHXgTndxRBrOjH/fA=="], - - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.91.0", "", { "os": "none", "cpu": "arm64" }, "sha512-gtqn4b0kLpGe46Qh7NHiR86wZpd+TLaesO106s7V3RG33r63VB94LW5qPKEDJX64osh3xfjnPFn1M0484bC7wQ=="], - - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.91.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KQItBdsyHN28U3z/bEek6h9pvG3Uqv564QYO8K1bE/V7YRZgpHlRnfNKVPHkk4sfYKyPzEjCgW80QkImOLvJog=="], - - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.91.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XFKqbbsQVmTgw+CyBKuzSZtAa45viom1naFfulKmkTFdDyDaBQaBqEz5J61TI8iwBynU+F8q7qPD+9mplZRhEg=="], - - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.91.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DmpK0PthG66Bcz5Y4JqNbzXltzyLQtg3ofYpd25+ejm7kQYUM78VL1uIP/ZFct2781mwUyBX37LatFPCTVp35A=="], - - "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="], - - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="], - - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="], - - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="], - - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="], - - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="], - - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="], - - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="], - - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="], - - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="], - - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="], - - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="], - - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="], "@changesets/apply-release-plan": ["@changesets/apply-release-plan@8.1.1", "", { "dependencies": { "@changesets/config": "^4.0.1", "@changesets/format": "^0.1.2", "@changesets/git": "^4.0.1", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "import-meta-resolve": "^4.2.0", "jsonc-parser": "^3.3.1", "semver": "^7.8.1" } }, "sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q=="], "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@7.0.0", "", { "dependencies": { "@changesets/errors": "^1.0.0", "@changesets/get-dependents-graph": "^3.0.0", "@changesets/should-skip-package": "^1.0.0", "@changesets/types": "^7.0.0", "semver": "^7.8.1" } }, "sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg=="], @@ -303,14 +213,82 @@ "@oxc-project/types": ["@oxc-project/types@0.156.0", "", {}, "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-z8x4kittQLPsrJWmfoIJyYqs2gNwceGShoUqAJdFRPw3K4kkkKCFHh3Cr4NFRvZb7DPfDS3+JfBjSm3OIyQaJQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Zj9fEQhQSKGj8ZTB+X4q4ho4545xWf7qsvJpwREWnBz5BATEFYtStKRgiwYOZvxQ7ORlBEgSj7pmDzuljfV1mQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZy8GpUXaWpic5JPMAvLmaBz95Sx0naZl6hptBPLOMiYmZYiME7jAt6pv8o/tjQqPNWKb+7jKzZhrYx6UqM5MQ=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U59+a9lVpoRp9enRhTE9GssL3qB1k09CYagRzTfs3dfknopMYV0x0xHZv7Pls3SNMqHlyHR+ufmY76CYafQoLw=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J1R3YJHPdQJ2RQvzMdTVDWyizhihGiRGGAeEZxtGp629j5pcT5ypSLSiBflopD7o7tmiMD+xlNfhIVmjcHKMVQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x9qTg6zHoqeyzmFVlIjSb9WlhZdoXaayMTeQeDeUEfKA484AgPJz7f9chLjDMONA6wUUX0FJxnVHsYgX5Zv0+g=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-303gbamm7NDllTtkeaX+8F5Da+KcSBr64mqELQEFg9XgnjfLX3YBCHUcOfgIlVTSybesy43eoeKai8oGr0P6UA=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j3UAXzCJoWX9bZLaICj6DfwkI/2fzja9RpiCIbIN+2CQAcKoY+5SH5pQUcECvn32mQm17NSPE9YqTAwYx1seZg=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qFZqWVkEzuxWMtUkCtHxsnohXHnUMBQr/ZIxF0KmlCSt8de/bc1lbpVgpqjb3QxCADQHG/O1s3JcowcBF2sqdw=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ROiAid0UH2GN5lMMX2JTgKlOKx1rN+6a1WxIuBsU3j5A3MuB9U5XkREMW76XBbAImnH45mfOCFcSIwb+drcVYg=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-Oyd/9ObdM2RYkZ7yzF/GyvVdbNgJcRG8BFVwnAVo3ooqzaem4E2p5eTIGB09FecQD+Q5HQEANOTEnIZTO6rBAQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-86DOpmPDR5UzHSOB8y5HlOpYAIBtMx+6L72IyousNx54q1Kz61aykB4ScovFYivZUak+7Ww6tPBUjarluzYKLw=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-u6omjjFooKfnHLBI+3UtBVInAeuEhoM/RAIw/OiFop2QQgOan5zDfqPR6guMaylt68L60kV+by07Ibp87KDjrw=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-L0/hW1MdMrgsvuLGp9YEQOYOGj5xPHa8wVRtybdd5av03PV4qSpAq3pyfC7sn7UCA8ynRhMRTAa9Kr9nV4LGZg=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JNk/7HaPDJjy5kih3vq6b71Qif3tHx1ZFACGBzeHeNzyKaIpar4eFGya89GR398K2rwX0eEVmEq1npBPlEkkmg=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-MDLrOQPXDuPKvPUCvpP3hC6kO72dsYxfpYiR08Nf5DiM75M0d8+D26JwT4uHDf0bcflvlGHcd58Hn2HpxkHUUQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2CcICDrQKqMAOFuzmts/8Ov5iKb+DW/QrW4MFsl2bnRg86uoiUvtqUkHqNSY19Shegc8pBHiXMSEHDjIg94o0A=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-NjZLJg+tQpMWVSyeWBBKCpib7H/uLq84ZzxCvY5eqHzSaItuzCECGA9MBiZwmW8fnkBIfppg49Ib//LSCGAp8Q=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0vCMaX/qloc7uaKBDRh+62SBW8EqmhS7c8/al+PxD594ao4lyEWdcoEmVoBgnCNVW07GDpg+9uPV7x6ta9mljw=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.91.0", "", { "os": "android", "cpu": "arm" }, "sha512-eOqJUUS1MCqIoUUxMIkuwaqIdKaHr1SXYA0CQI95UUrF8jwQylQH33bBmnqhZfvWXKlRB2Ylx/+Sbk/ctEqzZQ=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.91.0", "", { "os": "android", "cpu": "arm64" }, "sha512-vIJU4lRxK0EhbBt+iR3rkq3pio4I+V5Gshwjei3plNjN+CIq0WxWHzYVI06J9KN8Ar3hyNSFtzJAjVRLZOYpjg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.91.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tLy76MurwAOpQt5QVMkJUgexBSWhB7qdvAYb14bc0xntZS6f55aKicbZoxZ/QlmAbnrdlXeD5+IjwY8EZ9RWTg=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.91.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-zPeMmjAqm5vXxbTp/JqYuc9gGSbUuEaeCAkFHB4TCRLTlfJGD//Fkx03YvZHCHZJ7ideLiiLcae+3f2I27qnaA=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.91.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-psOR9+xGMXn0hj4KY5M2kjSQ1n0TCMdYbPOraoV0iQyl7mw2oBBYGUM2+ma40m1YLVrktTBOta4FjrmcOHDhrg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-1PNA1j4ETxawvxfz6ueDxWgQgWkXPbv44DWZmC7LR07GqojGFDOwWbosOT6/AYiJr7zMXEhn2dPWMkyFFOFpUQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.91.0", "", { "os": "linux", "cpu": "arm" }, "sha512-jcNePVpca37lTbHmiPMnVbPAAW0rH9kAoDjsDv6vESEX9pzazIR1q5SyYuc1rQzdjHtc2VVCDN9jDh6tHcOHyA=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.91.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TJi78UX6R+9qZ23ZKtzN6adfW83bjY0NXsCRMBRH6H7IizaMXmZbdRAtjedMkx77bT46YQH0csawsY9lwha8MA=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.91.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-OowlLroUTzKq5OBvbu6iegch8SJ2DivXFCukzPWURwvcJhMj7/P0CUGH5qrDlxT1Kh48dcxqYqrvE8qK6xTLKQ=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.91.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QF/5Ge8yGpWg5s4awMNHfVKh1WOmk591Qg082i6CHenXYBa+W1E5RwWZXaKUOw4Ko7CCjqg3TfBc0AzhnqblYg=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-dXlsKlNhzJQX1nageGKnvWe1D7uTng249hQM80CG/YPZ8F/VZHbxVLhImgurdWBOmu86XOemValLjrW6QIOM/Q=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.91.0", "", { "os": "linux", "cpu": "none" }, "sha512-tznkj5VrWNRUfymkaLcJetgMLbP9suL5ob/m7j0Fdbnm5u3Li/Ezo/YcBixiuwdL1sg0oGpyTe86It0J6T+Lxg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.91.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sNvfLiUYQkVsLNUgIW24AEYTHSd2zZ1vAxXssPdjuQHTtCpRvQihYri6d5z5FY7w/QrcnB5+g9E+/sqmZq8tfA=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.91.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eA25ETvimhUZ89y5BcunNY7yv7qwG2I7V7dFQAU+mIEqZ57gBCXdIERl0yXOEt43zjsw0yK2gRKTc+twF706wQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.91.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4hZ4Z9K+OkcTpAHDoUTMg/8MModbM0aocrl9KnGzp9h5iKqjrXraEUZorYoVCT7xoK93CAHXgTndxRBrOjH/fA=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.91.0", "", { "os": "none", "cpu": "arm64" }, "sha512-gtqn4b0kLpGe46Qh7NHiR86wZpd+TLaesO106s7V3RG33r63VB94LW5qPKEDJX64osh3xfjnPFn1M0484bC7wQ=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.91.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KQItBdsyHN28U3z/bEek6h9pvG3Uqv564QYO8K1bE/V7YRZgpHlRnfNKVPHkk4sfYKyPzEjCgW80QkImOLvJog=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.91.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XFKqbbsQVmTgw+CyBKuzSZtAa45viom1naFfulKmkTFdDyDaBQaBqEz5J61TI8iwBynU+F8q7qPD+9mplZRhEg=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.91.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DmpK0PthG66Bcz5Y4JqNbzXltzyLQtg3ofYpd25+ejm7kQYUM78VL1uIP/ZFct2781mwUyBX37LatFPCTVp35A=="], + "@playwright/test": ["@playwright/test@1.64.1", "", { "dependencies": { "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg=="], "@pnpm/deps.graph-sequencer": ["@pnpm/deps.graph-sequencer@1100.0.1", "", {}, "sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A=="], @@ -323,10 +301,36 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.16", "", { "os": "android", "cpu": "arm" }, "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.16", "", { "os": "android", "cpu": "arm64" }, "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.16", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.16", "", { "os": "linux", "cpu": "arm" }, "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.16", "", { "os": "linux", "cpu": "ppc64" }, "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.16", "", { "os": "linux", "cpu": "s390x" }, "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.16", "", { "os": "none", "cpu": "arm64" }, "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], diff --git a/docs/README.md b/docs/README.md index ba8e16a..e448cdb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,3 +29,10 @@ Use `bun run test:browser` locally, or set `E2E_BASE_URL` to test a hosted site without starting Vite. New tests in `tests/` are included automatically. Staging and alpha run the suite after deployment. Preview E2E failures fail the preview check; reports and failure traces are uploaded as workflow artifacts. + +## CMS extraction gate + +CMS is maintained in AstrologyEngine/cms. Deployment and preview creation are +disabled unless repository variable `CMS_DEPLOY_ENABLED=true`; enable only +after hosted provisioning/acceptance in web-issues#39. CMS must deploy and seed +before web. Contract release assets are independent of hosted deployment. diff --git a/docs/cms-extraction.md b/docs/cms-extraction.md new file mode 100644 index 0000000..bfcfd3f --- /dev/null +++ b/docs/cms-extraction.md @@ -0,0 +1,46 @@ +# Standalone CMS extraction + +CMS source, D1 migrations, tests and operations docs moved from `apps/cms` to +[AstrologyEngine/cms](https://github.com/AstrologyEngine/cms), initially based on +web commit `4f4348aaa24829c4db0f21d799c970533cdafe61`. Existing git history and +archive-import records are retained here. + +Web consumes the exact GitHub release tarball for `@astrologyengine/cms-contract`. +The package contains catalog types, locale helpers and baseline copy; regular +web installs and builds need no CMS checkout. Runtime reads still use HTTP in +development and the `CMS` Worker service binding when deployed. No API or D1 +schema change is introduced by extraction. + +`bun run dev:stack` runs sibling checkouts and temporarily links the local +contract. `CMS_REPO_PATH`, `WEB_PORT` and `CMS_PORT` select a different worktree +and ports. SIGINT/SIGTERM restore the pinned package and stop owned processes. +Do not run two stacks from one web checkout. If forcibly killed, inspect +`.cache/cms-stack-lock` and ensure its PID is no longer running; remove +only the stack-created package symlink, rename the recorded `.stack-backup` +back to the package path, then remove the lock. Never delete a backup blindly. + +Browser tests start isolated CMS instances from the chosen checkout. CI obtains +that checkout's exact SHA from the installed contract's `cmsRevision`. Fixtures +are resolved from web, independently of checkout layout. + +Deployment and preview creation require repository variable +`CMS_DEPLOY_ENABLED=true`, enabled only after the hosted setup gate in +[issue #39](https://github.com/AstrologyEngine/web-issues/issues/39). CMS deploys +and seeds first. Deployed web snapshots then fetch from the matching staging +or alpha CMS and fail the build if unreachable. Preview cleanup remains active. +Local builds use package baseline copy when `CMS_SNAPSHOT_ORIGIN` is absent. + +## Extraction verification + +The initial CMS release is `v0.1.0`, source commit +`e69a53e345c97a86d7cfb866cd0fcacea508963e`. Its hosted CI passed: +https://github.com/AstrologyEngine/cms/actions/runs/43506681296. + +Local checks passed: 158 CMS tests, eight CMS packaging/release tests, 17 web +Node tests, 36 web verification tests and 65 browser tests across all three +projects. Both applications passed type/lint/format checks and staging/alpha +Worker dry runs. The real stack was exercised with a custom checkout path +containing spaces and `$`, alternate ports, watched contract/seed edits, +restart persistence, the review wrapper, a competing launcher, and SIGTERM +restoration without manifest/lockfile changes. Hosted resources and service +binding acceptance remain unverified under issue #39. diff --git a/docs/designs/cms.md b/docs/designs/cms.md index 7a9f4b9..1586259 100644 --- a/docs/designs/cms.md +++ b/docs/designs/cms.md @@ -1,606 +1,7 @@ -# Design: Astrology Engine CMS +# CMS designs -| | | -| ----------- | ---------------------------------------------------------------------- | -| **Status** | Implemented (rev. 2 approved 2026-11-10); see §14 for as-built notes | -| **Date** | 2026-11-10 | -| **Code** | `AstrologyEngine-web/apps/cms` (new), `AstrologyEngine-web` (consumer) | -| **Related** | `docs/plans/cms.md` (plan), `docs/runbooks/cms.md` (operations) | +The CMS is now maintained in [AstrologyEngine/cms](https://github.com/AstrologyEngine/cms). +See the [CMS designs document](https://github.com/AstrologyEngine/cms/blob/main/docs/designs/cms.md). -**Revision 2 changes:** keys and collections are defined in code (no runtime -entry creation/deletion, no `entries` table); outage fallback uses per-language -static JSON snapshots bundled with the site; language fallback is an explicit, -editable per-locale chain that supports regional siblings (e.g. `es-ES` → -`es-419`); seeding runs on every deploy. - -## 1. Summary - -A small Cloudflare Worker (`AstrologyEngine-web/apps/cms`) backed by Cloudflare D1 becomes the single -source of truth for all user-facing copy on the Natal Chart Explorer website. It -serves anonymous, cacheable, read-only JSON to everyone, and lets administrators -who present a Bearer token (held in a Worker secret) manage locales and -translations. The set of content keys is defined in code, because only code -changes can consume a new key; the _text_ for every key, in every language, lives -in D1. English is the default and final fallback; Spanish or any other BCP 47 -locale can be added at runtime without a deploy. The website's SvelteKit server -reads copy through a Cloudflare **Service Binding** and ships a build-time JSON -snapshot per language as its outage fallback. - -## 2. Context - -Today every string the site shows is hardcoded: - -- `src/lib/reference-content.ts` and `src/lib/exploration-content.ts` — 51 - reference entries (15 bodies, 13 signs, 12 houses, 4 angles, 7 aspects, each an - insight plus keywords), 3 system explanations, the custom True Sky explanation, - the final-degree bridging note, a sign → ruler table written as English planet - names, and editorial review metadata. -- Svelte components and routes — landing page, page titles and meta description, - headings, button labels, ARIA labels, status and error messages, chip labels, - picker titles, settings labels, body/angle/sign display names - (`wheel/body-symbols.ts`, `title(sign)`), etc. -- Server and worker messages — `/api/resolve-birth` and `lib/server/birth/resolve.ts` - return English `message` strings; the calculation worker returns English error - messages. -- `scripts/editorial/review-packet.ts` renders the content files into a review - document. - -## 3. Decisions captured during review - -| # | Question | Decision | -| ---- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | Repo layout | CMS in `AstrologyEngine-web/apps/cms/` as its own package (no Bun workspace); `docs/` beside `AstrologyEngine-web/`. | -| 2 | Scope of copy | **All** user-facing copy: the `*-content.ts` files _and_ strings in components, routes, resolver and worker. | -| 3 | Rulers | Ruler mapping stays in code as body IDs (`MARS`, `PLUTO`); planet names exist once, in the CMS `body` entries. | -| 4/5 | Editorial review | Review packet script and editorial review metadata removed; revision history replaces provenance. | -| 6 | Schema shape | Generic tables (locales, translations) with per-collection field validation in the Worker. | -| 7 | Locales | BCP 47 tags; `en` is the default and cannot be deleted or disabled; new locales start disabled. | -| 8 | Missing translation | Fall back along the locale's chain, ending at the default; flag fallbacks; admin coverage endpoint across languages. | -| 9 | Key set | **Code-defined** (rev. 2). Collections and keys live in a catalog in the CMS code; admins edit text, not keys. | -| 10 | Spanish seed | Not seeded. Spanish content is out of scope; the system must support it. | -| 11 | Publishing | Writes are live immediately; every change is recorded in a revision history; optimistic concurrency via `ETag`/`If-Match`. | -| 12 | Public read shape | One whole-locale bundle endpoint plus locale list and single-entry endpoints. | -| 13 | Caching | `Cache-Control: public, max-age=60, stale-while-revalidate=600` + version-based `ETag`. | -| 14 | Auth | `CMS_ADMIN_TOKEN` secret (+ optional `CMS_ADMIN_TOKEN_NEXT` for rotation), constant-time compare; brute-force protection by a Cloudflare WAF rate-limiting rule. | -| 15 | CORS | Public GETs allow the site origins only; admin routes emit no CORS headers. No admin UI. | -| 16 | Site integration | SvelteKit server fetches through a Service Binding. | -| 17 | CMS unavailable | **Bundled static JSON snapshot per language** (rev. 2), see §9.5. No full outage. | -| 18 | Language selection | `?lang=` parameter persisted in a cookie; `Accept-Language` on first visit; switcher shown when more than one locale is enabled. | -| 19 | Environments | `staging` and `alpha` are the production environments for the new site; `astrologyengine.dev` (legacy) is untouched. | -| 20 | Tooling | Hono, zod, D1 migrations via Wrangler, Vitest + `@cloudflare/vitest-pool-workers`, Bun. | -| 13.3 | Resolver/worker messages | Become codes mapped to CMS copy on the client. Approved. | -| 13.4 | Hostnames | `cms-staging.` / `cms-alpha.astrologyengine.dev`. Approved. | -| 13.6 | Sibling locales | Supported through per-locale fallback chains (§7.3). | - -## 4. Goals and non-goals - -**Goals** - -- Every user-facing string on the site is served by the CMS. The web repo - contains no hand-written copy; its only copy is generated snapshots (§9.5) and - the last-resort static error page (§9.6). -- English and Spanish supported by the data model and API; any further BCP 47 - locale can be added at runtime, including regional variants that fall back to - each other. -- Anonymous, cacheable public reads; admin operations behind a Bearer token. -- Cheap to run and operate: one Worker and one D1 database per environment. - -**Non-goals (this work)** - -- Spanish copy, Spanish date/number formatting, or Spanish birth-input parsing - (the resolver parses English sentences via chrono-node). -- An admin UI, multiple admin identities, roles, drafts or scheduled publishing. -- Creating or deleting content keys at runtime. -- Rich text. Copy is plain text (§5.5). -- Pluralisation/ICU MessageFormat. Current copy needs none; placeholders only. -- Copy embedded in non-text assets (SVG glyph paths, the sample wheel drawing). - -## 5. Content model - -### 5.1 Catalog - -An **entry** is identified by `(collection, key)` and has zero or one -**translation** per locale. A translation is a JSON object of **fields** whose -shape is determined by the collection. - -Collections _and_ keys are declared in one dependency-free TypeScript module, -`AstrologyEngine-web/apps/cms/src/catalog.ts` (`as const` data). It is the contract between the CMS -and the website: - -- The CMS validates every admin write against it (`404 unknown_entry` for keys - not in the catalog) and builds bundles from it. -- The website imports its **types** (`import type` through a `$cms` alias to - `apps/cms/src`), so `t("landing.hero.titel")` is a compile error. Type-only - imports are erased; no CMS code is bundled into the site. -- Adding a key = edit the catalog + add its English text to the seed + use it in - the site, in one change. Removing a key = delete it from the catalog; existing - translations become **orphans**, which are excluded from public output, listed - by coverage and deletable by admins. - -Initial collections: - -| Collection | Keys (initial) | Fields | Replaces | -| ---------- | ----------------------------------------------------------- | ----------------------------- | ----------------------------------------------------------------------------- | -| `body` | `SUN` … `CERES` (15) | `name`, `insight`, `keywords` | `meanings`, `bodySymbols[].name` | -| `angle` | `ASCENDANT_SYMBOL`, `MIDHEAVEN`, `DESCENDANT`, `IMUM_COELI` | `name`, `insight`, `keywords` | `angles`, angle names in `Wheel.svelte` / `bodySymbols` | -| `sign` | `aries` … `pisces` incl. `ophiuchus` (13) | `name`, `insight`, `keywords` | `signs`, `title(sign)` display | -| `aspect` | `Conjunction` … `Opposition` (7) | `name`, `insight`, `keywords` | `aspectContent`, aspect type display | -| `house` | `1` … `12` | `insight`, `keywords` | `houses` | -| `ui` | dotted keys, e.g. `landing.hero.title` (~130) | `text` | all other strings, incl. system help, bridging note, resolver/worker messages | - -### 5.2 Field rules - -Validated on every write; unknown fields are rejected. - -- `name`: 1–100 chars, trimmed, non-empty. -- `insight`: 1–2000 chars. -- `keywords`: array of 1–8 strings, each 1–50 chars. -- `text`: 1–2000 chars. - -`ui` key convention: `.[.]` in lower camel segments, e.g. -`app.entry.heading.withBirth`, `birth.chip.timeNeeded`, `error.resolver.range`, -`system.trueSky.help`. - -### 5.3 Rulers - -`rulers` moves to web code as -`Record` (e.g. -`scorpio: ["MARS", "PLUTO"]`). The site renders `body[id].name`. Ophiuchus has -no entry and renders `ui: details.house.noRuler`. Planet names therefore appear -exactly once, in `body` entries. - -### 5.4 Placeholders - -Strings may contain named placeholders `{name}` (ASCII identifier in braces), -e.g. `"Showing the previous chart for {date} at {time} in {place}."`. The -catalog declares each `ui` key's placeholders, and writes whose text uses a -different set are rejected with `422 placeholder_mismatch`. The site substitutes -values; an unsubstituted placeholder stays visible rather than failing silently. - -### 5.5 Plain text only - -Copy is never rendered as HTML (`{@html}` is not used), which removes an XSS path -from anyone holding the admin token. The only formatting convention is `\n`, -which components that opt in (the landing-page title) render as a line break. - -### 5.6 Removed - -`EditorialReview`, `pendingReview()`, `bodyReviews`, `systemHelpReview`, -`rulerReview`, and `scripts/editorial/review-packet.ts` are deleted. Who changed -what and when is answered by the revision history (§6.4). - -## 6. Storage (D1) - -### 6.1 Schema - -```sql -CREATE TABLE locales ( - code TEXT PRIMARY KEY, -- canonical BCP 47, e.g. 'en', 'es', 'es-419' - name TEXT NOT NULL, -- endonym for the switcher, e.g. 'Español' - enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), - is_default INTEGER NOT NULL DEFAULT 0 CHECK (is_default IN (0, 1)), - fallback TEXT REFERENCES locales (code) ON DELETE SET NULL, -- next hop, §7.3 - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); -CREATE UNIQUE INDEX locales_single_default ON locales (is_default) WHERE is_default = 1; - -CREATE TABLE translations ( - collection TEXT NOT NULL, - key TEXT NOT NULL, - locale TEXT NOT NULL REFERENCES locales (code) ON DELETE CASCADE, - fields TEXT NOT NULL CHECK (json_valid(fields)), - revision INTEGER NOT NULL DEFAULT 1, -- per-translation ETag source - updated_at TEXT NOT NULL, - PRIMARY KEY (collection, key, locale) -); -CREATE INDEX translations_locale ON translations (locale); - -CREATE TABLE revisions ( - id INTEGER PRIMARY KEY, - subject TEXT NOT NULL CHECK (subject IN ('translation', 'locale')), - action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), - collection TEXT, - key TEXT, - locale TEXT, - before TEXT, -- JSON snapshot or NULL - after TEXT, -- JSON snapshot or NULL - actor TEXT NOT NULL, - created_at TEXT NOT NULL -); -CREATE INDEX revisions_lookup ON revisions (collection, key, locale, id); - -CREATE TABLE meta ( - key TEXT PRIMARY KEY, - value INTEGER NOT NULL -); --- migration seeds: meta ('content_version', 1); locale ('en', 'English', enabled, default) -``` - -Timestamps are ISO-8601 UTC strings. Foreign keys are enforced (D1 default). -There is no `entries` table: entry existence is defined by the catalog. - -### 6.2 Writes - -Every mutating request executes as one `db.batch([...])` (atomic in D1): the -change itself, a `revisions` insert and -`UPDATE meta SET value = value + 1 WHERE key = 'content_version'`. -Optimistic-concurrency checks are expressed in the `WHERE` clause of the update -(`… AND revision = ?`), so there is no read-then-write race; zero affected rows -yields `412`. - -### 6.3 Reads - -The bundle for locale `L` reads `content_version`, then all translations for the -locales in `L`'s fallback chain (`WHERE locale IN (…)`, at most a few hundred -rows), and resolves each catalog entry in code by chain order. Responses are on -the order of 25–40 KB uncompressed. - -### 6.4 Revisions - -Append-only. `actor` is taken from an optional `X-CMS-Actor` request header -(1–64 chars, e.g. an editor's name or a script name), default `admin`. Because all -admins share a token, `actor` is informational, not authenticated. Reverting is a -manual read-revision-then-PUT. Seeding (§8) does not write revisions (it only -inserts missing rows); no retention limit. - -## 7. API - -Base path `/v1`. JSON in and out (`application/json; charset=utf-8`). Request -bodies are limited to 64 KB. Errors use one shape: - -```json -{ - "error": { - "code": "locale_not_found", - "message": "Locale 'xx' is not available." - } -} -``` - -Codes: `bad_request`, `validation_failed` (with `issues`), `unauthorized`, -`not_found`, `locale_not_found`, `unknown_entry`, `conflict`, -`precondition_failed`, `placeholder_mismatch`, `fallback_cycle`, -`method_not_allowed`, `payload_too_large`, `admin_disabled`, `internal`. - -### 7.1 Public (anonymous; GET/HEAD/OPTIONS only) - -| Method & path | Description | -| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /v1/locales` | Enabled locales, the default and each locale's effective chain: `{ "default": "en", "locales": [{ "code": "en", "name": "English", "chain": ["en"] }] }` | -| `GET /v1/content/:locale` | Whole bundle for an enabled locale (shape below). | -| `GET /v1/content/:locale/:collection/:key` | One entry: `{ collection, key, locale, fallback, fields }`. | -| `GET /health` | `{ "ok": true, "version": }`; checks D1 connectivity. | - -Bundle shape: - -```json -{ - "locale": "es-ES", - "default": "en", - "chain": ["es-ES", "es-419", "en"], - "version": 42, - "entries": { - "body": { - "SUN": { "name": "Sol", "insight": "…", "keywords": ["…", "…"] } - }, - "ui": { "landing.hero.title": { "text": "…" } } - }, - "fallbacks": { "ui:landing.hero.title": "en" } -} -``` - -`fallbacks` maps `collection:key` to the locale actually used, for entries not -served in the requested locale. A disabled or unknown locale returns -`404 locale_not_found`. A catalog entry with no translation anywhere in the chain -is omitted; the seed (§8) prevents this for the default locale. - -Any non-GET/HEAD/OPTIONS method outside `/v1/admin` returns `405`. - -### 7.2 Admin (`Authorization: Bearer ` required) - -| Method & path | Description | -| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET /v1/admin/locales` | All locales including disabled, with `fallback` and effective chain. | -| `POST /v1/admin/locales` | `{ code, name, enabled?, fallback? }`. Code canonicalised with `Intl.getCanonicalLocales`; `409` if it exists. Default `fallback` per §7.3. | -| `PATCH /v1/admin/locales/:code` | `{ name?, enabled?, fallback? }`. The default locale cannot be disabled and has no fallback; cycles are rejected (`422 fallback_cycle`). | -| `DELETE /v1/admin/locales/:code` | Deletes the locale and its translations; locales pointing to it get `fallback = NULL`. Default locale refused (`409`). | -| `GET /v1/admin/catalog` | The catalog: collections, keys, field shape, placeholders. | -| `GET /v1/admin/entries/:collection/:key` | One entry with every stored translation and its `revision`. | -| `PUT /v1/admin/entries/:collection/:key/translations/:locale` | Create or replace one translation. `404 unknown_entry` if not in catalog. Returns it with `ETag: "r"`. | -| `DELETE /v1/admin/entries/:collection/:key/translations/:locale` | Delete one translation (including orphans). Refused for the default locale unless the entry is an orphan. | -| `GET /v1/admin/coverage` | Per locale: translated count, missing ids, ids served by fallback and from which locale, placeholder mismatches; plus orphaned translations; `complete: boolean`. | -| `GET /v1/admin/revisions?collection=&key=&locale=&before=&limit=` | Newest-first history, cursor pagination by `id`, `limit` ≤ 200. | - -Concurrency on `PUT …/translations/:locale`: - -- `If-Match: "r"`: replace only if current revision is `n`, else `412`. -- `If-None-Match: *`: create only, `412` if it exists. -- Neither header: unconditional upsert (convenient for scripts). Editors using - tooling are expected to send `If-Match`. - -Admin responses carry `Cache-Control: no-store` and no CORS headers. - -### 7.3 Locale fallback chains (sibling support) - -Each locale has an optional `fallback` pointer to another locale. The effective -chain for `L` follows the pointers, **skipping disabled locales**, and always -ends at the default locale: - -``` -es-ES (fallback: es-419) → es-419 (fallback: null) → en -es-MX (fallback: es-419) → es-419 → en -pt-PT (fallback: pt-BR) → pt-BR → en -``` - -- **Automatic default on creation** when `fallback` is omitted: the base - language if it exists (`es-MX` → `es`), else an existing sibling with the same - language (`es-ES` → `es-419`; if several, the alphabetically first, editable - later), else `NULL` (straight to the default). -- Editable any time with `PATCH`; cycles are rejected; deleting a locale nulls - pointers to it. -- The chain is returned by `GET /v1/locales`, in bundles and in coverage, so it is - never implicit. - -**Negotiation** (website, §9.2) uses the same idea: for an -`Accept-Language: es-ES` visitor when only `es-419` is enabled, the match order -is exact tag → base language → any enabled sibling with the same language → -default. - -### 7.4 Authentication - -- Secrets: `CMS_ADMIN_TOKEN` (required for admin), `CMS_ADMIN_TOKEN_NEXT` - (optional, accepted in addition during rotation). -- Comparison: SHA-256 both the presented and configured tokens, then - `crypto.subtle.timingSafeEqual` on the digests (equal length, constant time). -- Fail closed: if `CMS_ADMIN_TOKEN` is unset or shorter than 32 characters, every - admin route returns `503 admin_disabled`. -- Failure: `401` with `WWW-Authenticate: Bearer realm="cms"`. Tokens are never - logged. -- Brute-force protection: Cloudflare WAF rate-limiting rule on `/v1/admin/*` - (runbook), not in code. - -### 7.5 CORS - -`CMS_ALLOWED_ORIGINS` (comma-separated var per environment). For public routes, -a matching `Origin` gets `Access-Control-Allow-Origin: `, -`Vary: Origin`, methods `GET, HEAD, OPTIONS`; preflight answered with `204`. -Non-matching origins receive no CORS headers. Admin routes never emit CORS -headers, so browsers cannot call them cross-origin. - -### 7.6 Caching - -- Public responses: `Cache-Control: public, max-age=60, stale-while-revalidate=600`, - `ETag: "v"`. `If-None-Match` is checked after a single-row - read of `meta` and answered `304` without reading translations. -- Worker-generated responses are not stored by Cloudflare's CDN, so these headers - govern browsers and other HTTP clients. The website keeps its own in-isolate - cache (§9.4). Net effect: edits are visible on the site within about a minute. - -### 7.7 Observability - -Observability enabled as in the web app (logs on, query strings redacted). Each -admin write emits one structured log line `{ event: "cms_write", subject, -action, collection, key, locale, actor, version }`. No request bodies or tokens -are logged. - -## 8. Seeding - -`AstrologyEngine-web/apps/cms/seed/en.json` holds English text for every catalog entry, extracted -from the current code, in bundle `entries` shape. A test asserts the seed and the -catalog contain exactly the same keys and that every seed value passes field -validation. - -`bun run seed:` converts the seed into SQL -(`INSERT … ON CONFLICT (collection, key, locale) DO NOTHING`, plus one -`content_version` bump) and runs it with `wrangler d1 execute`. Because existing -rows are never touched, the seed is safe to run **on every deploy**. That is how -new catalog keys get their initial English text before the website that uses -them goes live. After a key is seeded, D1 is the source of truth and later edits -to the seed file have no effect on that environment. Spanish is not seeded. - -## 9. Website integration - -### 9.1 Binding - -Web `wrangler.jsonc` gains a service binding `CMS` per environment (bindings are -not inherited by Wrangler environments): - -| Web env | Binding target | -| ----------------------------- | ------------------------------ | -| top level (local) | `astrology-engine-cms-local` | -| `staging` (incl. PR previews) | `astrology-engine-cms-staging` | -| `alpha` | `astrology-engine-cms-alpha` | - -`src/cloudflare.d.ts` declares `CMS: Fetcher`. In `vite dev`, where the existing -code already bypasses Cloudflare bindings (`dev ? fetch(path) : env.ASSETS…`), -the same pattern is used: `dev ? fetch(CMS_ORIGIN + path) : env.CMS.fetch(…)`, -with `CMS_ORIGIN` defaulting to `http://127.0.0.1:8787` (the local `wrangler dev` -CMS). - -### 9.2 Locale negotiation (`hooks.server.ts`) - -1. `?lang=` if it negotiates to an enabled locale → use it and set cookie - `lang` (`Path=/; Max-Age=31536000; SameSite=Lax; Secure` outside dev). -2. Else cookie `lang` if it still negotiates to an enabled locale. -3. Else best match from `Accept-Language` by q-value, each tag matched as in §7.3. -4. Else the default locale. - -The result goes into `event.locals.locale`; `app.html` uses `` -filled via `transformPageChunk`. SSR responses get -`Vary: Cookie, Accept-Language`. - -### 9.3 Loading and using copy - -- `src/routes/+layout.server.ts` loads `{ locale, locales, content }` through the - copy source (§9.4–9.5); all pages receive it through `page.data`. -- `src/lib/copy.ts` offers typed helpers used by components: `t(key, params?)` - for `ui` text, `entry(collection, key)` for reference content, plus - `bodyName(id)`, `signName(id)`, `aspectName(type)`. Keys are typed from the - catalog (§5.1). -- A key missing at runtime (only possible if the CMS and site are out of step) - renders the key itself and logs a warning; reference lookups return - `undefined` and the details panel shows `ui: details.unavailable`. Nothing - crashes. -- Resolver (`resolve.ts`, `/api/resolve-birth`) and calculation worker stop - producing English: they return stable `code`s (most already exist; new ones - added for each `needs_input` case) and the client maps them to - `ui: error.resolver.` / `error.calculation.`. The response drops - `message` and adds `code` on `needs_input`. -- Language switcher: rendered in the header only when more than one locale is - enabled; links to `?lang=`. With only English enabled it does not appear. - -### 9.4 Web-side cache - -Module-level cache per isolate, keyed by locale: fresh for 60 s; after that the -server revalidates with `If-None-Match` (cheap `304`). CMS calls time out after -2 s. The enabled-locale list is cached the same way. - -### 9.5 Bundled snapshot fallback - -At build time, `scripts/cms-snapshot.mjs` writes one file per enabled locale plus -an index into `static/content/` (gitignored, generated like `release.json`): - -``` -static/content/index.json { "default": "en", "version": 42, "locales": [...] } -static/content/en.json full bundle, same shape as GET /v1/content/en -``` - -- **Deployed builds** fetch from the target environment's public CMS - (`CMS_SNAPSHOT_ORIGIN=https://cms-staging.astrologyengine.dev`, etc.). If - that fails, the build fails, so a release never ships without a snapshot. -- **Local builds, dev and tests** without `CMS_SNAPSHOT_ORIGIN` generate the - snapshot from the CMS seed (`apps/cms/seed/en.json`). - -Resolution order on the server for each request: - -1. Fresh in-isolate cache. -2. Live CMS via the binding (revalidating the cache). -3. Stale in-isolate cache (any age; it is newer than the snapshot). -4. Bundled snapshot for the negotiated locale, else the first snapshot in its - chain, else `en.json`. Read through `env.ASSETS` and cached in the isolate. - -Every degraded response logs `{ event: "cms_fallback", source, locale }` so -outages are visible. Snapshot copy can be as old as the last deploy, which is -accepted for outages. - -### 9.6 Last resort - -If even the bundled snapshot can't be read (the site's own assets are broken), -the root layout load throws a `503` and SvelteKit serves the static -`src/error.html` with a one-line hardcoded message. This page is the only -hand-written copy left in the web repo. - -## 10. Environments and deployment - -| | Local | Staging | Alpha | -| --------------- | -------------------------------------------------------- | ------------------------------------- | ----------------------------------- | -| CMS Worker | `astrology-engine-cms-local` (`wrangler dev`, port 8787) | `astrology-engine-cms-staging` | `astrology-engine-cms-alpha` | -| D1 database | local SQLite in `.wrangler/` | `astrology-engine-cms-staging` | `astrology-engine-cms-alpha` | -| Public hostname | `127.0.0.1:8787` | `cms-staging.astrologyengine.dev` | `cms-alpha.astrologyengine.dev` | -| Allowed origins | `http://127.0.0.1:4173`, `http://localhost:5173` | `https://staging.astrologyengine.dev` | `https://alpha.astrologyengine.dev` | - -- Hostnames are first-level subdomains so the zone's Universal SSL certificate - (`*.astrologyengine.dev`) covers them. -- `workers_dev` and `preview_urls` are off for the CMS. -- Staging and alpha are treated as production for the new site; the legacy apex - `astrologyengine.dev` is untouched. -- Deploy order: CMS migrate → CMS deploy → seed (every time; idempotent) → web - build (snapshot from that environment's CMS) → web deploy. Migrations are - additive-only, so a CMS deploy never breaks the running site. -- Details, commands and recovery procedures: `docs/runbooks/cms.md`. - -## 11. Testing - -- **CMS:** Vitest with `@cloudflare/vitest-pool-workers` runs the Worker in - `workerd` against a real local D1 with migrations applied: routing, auth - (including rotation and fail-closed), validation, catalog enforcement, - fallback chains (siblings, disabled hops, cycles), ETags / 304, concurrency - (`If-Match`), placeholders, coverage (missing, fallback, orphans), revisions, - CORS, seed ⇔ catalog parity, seed idempotence. -- **Web:** existing Playwright journeys keep their English assertions. The - Playwright config starts a seeded local CMS and the web dev server. New tests - cover locale negotiation (`?lang`, cookie, `Accept-Language`, sibling match), - the switcher hidden with one locale, rendering with a partially translated - locale, and the CMS being down (snapshot copy served). - `verification/birth.test.ts` moves from English message assertions to code - assertions. -- **Types:** `check` / `check:ts` enforce that every key the site uses exists in - the catalog. - -## 12. Security considerations - -- Public surface is read-only; all writes require the Bearer token. -- Token stored only as a Worker secret; ≥ 32 chars enforced; rotation without - downtime via `CMS_ADMIN_TOKEN_NEXT`. -- Plain-text rendering only, so a leaked token cannot inject script into the - site (it can still deface copy; revisions make that recoverable). -- Parameterised SQL everywhere; request size capped; input validated with zod; - writes limited to catalog keys. -- WAF rate limiting on admin paths. - -## 13. Points needing your explicit approval (rev. 2) - -1. **Snapshot build dependency (§9.5):** deployed web builds _fail_ if the CMS - can't be reached for the snapshot, rather than shipping the seed's English. - The alternative (silently falling back to the seed) risks shipping stale copy - unnoticed. -2. **Sibling default (§7.3):** when a new regional locale has no base-language - locale, its fallback is set automatically to an existing sibling - (alphabetically first if several). It is shown and editable, never hidden. -3. **Catalog as the shared contract (§5.1):** the web app type-imports the CMS - catalog across the two package directories via an alias. This avoids a root - workspace (decision 1a) while keeping keys type-checked. - -## 14. As built (2026-11-10) - -Implemented per `docs/plans/cms.md`. Deviations and clarifications: - -1. **SvelteKit 2.** The web app moved from SvelteKit 3.1.1 to 2.59.1 with the - official Svelte toolchain (plan, Phase 0), because the sandbox registry has - no compatible Cloudflare adapter. It is one revertable commit. Bindings are - read from `platform.env`. -2. **Runtime import of locale rules.** Besides type-importing the catalog, the - website imports `negotiate`/`parseAcceptLanguage` from - `$cms/locales` at runtime, so negotiation matches the CMS exactly. The - module is dependency-free. -3. **Outage back-off (§9.4).** After a failed CMS call, the web isolate reuses - the stale or snapshot copy for 60 s before trying again, so visitors don't - each wait for the 2 s timeout during an outage. A bundle request for a - locale that was disabled meanwhile (`404`) falls back to the default - locale's bundle. -4. **Language switcher** links use `data-sveltekit-reload` (full page load), so - `` and all server-rendered copy change together. The `lang` - cookie is `HttpOnly`. -5. **Codes.** The resolver returns `code` on `needs_input` (`RANGE`, - `EXPLICIT_TIMEZONE`, `CHOOSE_DATE`, `DATE_INCOMPLETE`, `TIME_MISSING`, - `CHOOSE_PLACE`, `PLACE_NOT_FOUND`, `TIME_SKIPPED`, `TIME_AMBIGUOUS`, - `TIME_IN_FUTURE`) and no `message`. The previously shared `INVALID_INPUT` - error was split so each message keeps its own copy: `INVALID_BIRTH`, - `EMPTY_INPUT`, `INPUT_TOO_LONG`, `INVALID_INPUT`. The worker adds - `NO_VERIFIED_DATA`. Mapping lives in `src/lib/messages.ts`. -6. **Inventory.** 51 reference entries and 166 `ui` strings, including two new - strings: `language.switcher` and `details.unavailable` (shown if a reference - entry is missing). -7. **Not treated as copy (unchanged, English):** - - The prefilled birth sentence (`"{date} at {time} in {place}"` in the - editor) is input for the English sentence parser. - - Date, time and month-label formatting (`birth-display.ts`, resolver date - labels) is out of scope (§4). - - House-system names reported by the engine (`chart.house_system`) are shown - as provided, inside localisable templates. - - Internal error identifiers and schema diagnostics are never displayed. - - `src/error.html` (§9.6). -8. **Visible change.** The body details "Sign" value now renders the CMS sign - name ("Cancer") instead of the raw id ("cancer") that CSS capitalised. - It looks the same on screen; one Playwright assertion was updated. -9. **Guard.** `scripts/check-copy.mjs` (part of `lint`) uses the Svelte parser - to reject text nodes with letters and literal `aria-label`, `placeholder`, - `title`, `alt` or `label` attributes in `src/**/*.svelte`. -10. **CMS test runtime.** `@cloudflare/vitest-plugin` (the renamed - `vitest-pool-workers`) holds `compatibility_date` at 2026-09-25. -11. **Location.** The CMS lives in `AstrologyEngine-web/apps/cms/`. It was first - built in `/workspace/apps/cms` and moved there on request. It keeps its own - `package.json`, lockfile, lint/format configs and tests. The web app's - oxlint/oxfmt run with `--disable-nested-config` and ignore `apps/**`, and - Vite ignores `apps/cms/.wrangler/` (local D1 state). +Original history remains in this repository. The extraction and web integration +are described in [the extraction record](../cms-extraction.md). diff --git a/docs/plans/cms.md b/docs/plans/cms.md index 632c977..447aebe 100644 --- a/docs/plans/cms.md +++ b/docs/plans/cms.md @@ -1,334 +1,7 @@ -# Implementation plan: Astrology Engine CMS +# CMS plans -| | | -| ----------- | --------------------------------------------------------------------- | -| **Status** | Implemented 2026-11-10 (Phase 0 approved incl. SvelteKit 2 downgrade) | -| **Date** | 2026-11-10 | -| **Design** | `docs/designs/cms.md` (rev. 2, approved) | -| **Runbook** | `docs/runbooks/cms.md` | +The CMS is now maintained in [AstrologyEngine/cms](https://github.com/AstrologyEngine/cms). +See the [CMS plans document](https://github.com/AstrologyEngine/cms/blob/main/docs/plans/cms.md). -## 0. Findings that shape this plan - -Verified in the sandbox before planning (throwaway copies under `/tmp`, repo -untouched): - -1. **The web app cannot be installed from this registry.** 21 locked tarballs - are missing: the entire `@rsvelte/*` toolchain (Svelte compiler override, - formatter, svelte-check, oxlint plugin), `@sveltejs/adapter-cloudflare@8.0.2`, - `wrangler@4.157.0` and its `miniflare`/`workerd`, plus a few transitive - packages. -2. **No available Cloudflare adapter works with SvelteKit 3.1.1.** The only 8.x - adapter here (`8.0.0-next.6`) calls `generateManifest`, which SvelteKit 3.1 - removed. The adapter that works is `7.2.9`, which needs SvelteKit 2. -3. **A SvelteKit 2.59.1 build is green.** The combination is SvelteKit 2.59.1, - adapter 7.2.9, the official `@sveltejs/vite-plugin-svelte@7.3.1`, - `svelte-check@4.7.8` and `wrangler@4.156.0`, with Svelte 5.57.1, Vite 8.3.5 - and TypeScript unchanged. With those versions and the small code changes in - Phase 0: - - `build` succeeds; - - `bun test verification` passes (8/8) and `node --test scripts` passes; - - **all 43 Playwright tests pass**. -4. **Node 22 is fine.** Wrangler 4.156 requires Node ≥ 22; `wrangler dev`, local - D1 migrations and the Workers Vitest integration all run on Node 22.21. -5. **Browser binaries can't be downloaded here** (`cdn.playwright.dev` doesn't - resolve). Chromium 153 from npm (`@sparticuz/chromium`) works with Playwright - 1.64 when passed as `executablePath`. -6. **Workers Vitest integration:** `@cloudflare/vitest-pool-workers` is - deprecated in favour of `@cloudflare/vitest-plugin@1.1.13` (Vitest 4.1). Its - bundled runtime supports compatibility dates up to **2026-09-25**, so the CMS - uses that date. -7. **Formatting:** with `printWidth: 80`, `oxfmt` reproduces the existing - formatting of all `.ts`/`.js` files. It does not handle `.svelte` files. For - those, `prettier-plugin-svelte` differs from the current formatting in 7 of - 10 files, so they get a one-time reformat. - -## Method: red-green TDD - -All new behaviour is built test-first: - -1. **Red**: write the smallest test that states the next behaviour, run it and - confirm it fails _for the expected reason_ (not a typo or a missing import). -2. **Green**: write the least code that makes it pass; run the whole suite. -3. **Refactor** with the suite green. - -Each cycle is committed as a pair: `test(scope): … [red]` (only the failing -test), then `feat(scope): …` (code that turns it green). That keeps the red step -visible in history. Only the `[red]` commits are expected to fail; every other -commit is green. - -Where TDD doesn't apply, the existing suite is the safety net and must stay -green at every commit: - -- Phase 0 (toolchain swap): no new behaviour; the existing 43 Playwright tests, - verification tests and script tests. -- Phase 6 copy extraction (a refactor with unchanged English output): the - existing Playwright tests. New behaviour in Phase 6, such as resolver codes, - placeholder substitution and the hardcoded-copy guard, still goes red-green. - -## Phase 0 — Make the web app installable (approved) - -Recommended option: move to SvelteKit 2 with the official Svelte toolchain. The -code changes are small, and the alternative (waiting for registry access) blocks -all web work. - -**`AstrologyEngine-web/package.json`** - -| Package | From | To | -| ------------------------------ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `@sveltejs/kit` | 3.1.1 | 2.59.1 | -| `@sveltejs/adapter-cloudflare` | 8.0.2 | 7.2.9 | -| `@sveltejs/vite-plugin-svelte` | `npm:@rsvelte/vite-plugin-svelte@0.5.3` | 7.3.1 (official compiler); `overrides` removed | -| `@rsvelte/svelte-check` | 0.5.32 | replaced by `svelte-check@4.7.8` | -| `@rsvelte/fmt` | 0.7.25 | replaced by `oxfmt` (ts/js/json, already a dependency) + `prettier` / `prettier-plugin-svelte` (`.svelte` only) | -| `@rsvelte/oxlint-plugin` | 0.2.6 | removed (oxlint still lints `