From afc3e55c0ad75943be224d30f99dc4f3b21924c2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 13:03:03 +0200 Subject: [PATCH] feat: the analytics plugin @eisbachcode/emdash-plugin-analytics brings Cloudflare Web Analytics into EmDash and joins it to the content: a dashboard widget, an Analytics page with a setup check, per-entry numbers over 7 and 30 days with totals across translations, and a panel in the entry editor. It runs in-process or sandboxed within EmDash's ten-call budget per invocation, and reads English and German. The workspace brings the internal shared helpers, a compat matrix that runs the suites against other EmDash releases, and changesets for versioning. Requires EmDash 0.39.0 or later; tested on 0.39.0, 0.39.1 and 0.40.0. --- .changeset/README.md | 5 + .changeset/config.json | 13 + .github/workflows/compat-matrix.yml | 65 + .gitignore | 5 + .nvmrc | 1 + AGENTS.md | 54 + CLAUDE.md | 1 + README.md | 17 +- package.json | 19 + packages/analytics/.agents/skills | 1 + packages/analytics/.claude/CLAUDE.md | 1 + packages/analytics/.claude/skills | 1 + packages/analytics/.gitignore | 2 + packages/analytics/AGENTS.md | 50 + packages/analytics/README.md | 298 + packages/analytics/emdash-plugin.jsonc | 149 + packages/analytics/package.json | 71 + .../skills/creating-plugins/SKILL.md | 29 + packages/analytics/src/astro/index.ts | 205 + packages/analytics/src/i18n.ts | 439 + packages/analytics/src/index/bootstrap.ts | 246 + packages/analytics/src/index/entries.ts | 203 + packages/analytics/src/index/paths.ts | 136 + packages/analytics/src/plugin.ts | 362 + .../analytics/src/providers/cloudflare.ts | 578 ++ packages/analytics/src/providers/demo.ts | 259 + packages/analytics/src/providers/types.ts | 156 + packages/analytics/src/settings.ts | 117 + packages/analytics/src/store/access.ts | 114 + packages/analytics/src/store/rows.ts | 172 + packages/analytics/src/store/views.ts | 135 + packages/analytics/src/sync/older.ts | 113 + packages/analytics/src/sync/scheduler.ts | 763 ++ packages/analytics/src/sync/window.ts | 161 + packages/analytics/src/ui/blocks.ts | 227 + packages/analytics/src/ui/content.ts | 480 + packages/analytics/src/ui/format.ts | 115 + packages/analytics/src/ui/page.ts | 454 + packages/analytics/src/ui/panel.ts | 113 + packages/analytics/src/ui/setup.ts | 430 + packages/analytics/src/ui/status.ts | 65 + packages/analytics/src/ui/widget.ts | 185 + packages/analytics/tests/astro.test.ts | 209 + packages/analytics/tests/backfill.test.ts | 61 + packages/analytics/tests/bridge-calls.ts | 55 + packages/analytics/tests/budget.test.ts | 339 + packages/analytics/tests/cloudflare.test.ts | 469 + packages/analytics/tests/content-sync.test.ts | 240 + packages/analytics/tests/content.test.ts | 236 + packages/analytics/tests/demo-sync.test.ts | 124 + packages/analytics/tests/demo.test.ts | 104 + packages/analytics/tests/errors.test.ts | 74 + packages/analytics/tests/host.ts | 135 + packages/analytics/tests/i18n.test.ts | 128 + packages/analytics/tests/page-sync.test.ts | 68 + packages/analytics/tests/page.test.ts | 277 + packages/analytics/tests/panel.test.ts | 146 + packages/analytics/tests/paths.test.ts | 100 + packages/analytics/tests/plugin.test.ts | 371 + packages/analytics/tests/rows.test.ts | 112 + packages/analytics/tests/setup-sync.test.ts | 125 + packages/analytics/tests/setup.test.ts | 224 + packages/analytics/tests/views-sync.test.ts | 237 + packages/analytics/tests/views.test.ts | 130 + packages/analytics/tests/widget.test.ts | 229 + packages/analytics/tests/window.test.ts | 128 + packages/analytics/tsconfig.json | 21 + packages/analytics/tsdown.config.ts | 25 + packages/analytics/vitest.config.ts | 6 + packages/shared/package.json | 22 + packages/shared/src/index.ts | 3 + packages/shared/src/roles.ts | 48 + packages/shared/src/sweep.ts | 79 + packages/shared/src/values.ts | 33 + packages/shared/tests/roles.test.ts | 35 + packages/shared/tests/sweep.test.ts | 81 + packages/shared/tests/values.test.ts | 31 + packages/shared/tsconfig.json | 19 + pnpm-lock.yaml | 8361 +++++++++++++++++ pnpm-workspace.yaml | 35 + scripts/compat-matrix.sh | 124 + 81 files changed, 20253 insertions(+), 1 deletion(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .github/workflows/compat-matrix.yml create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 package.json create mode 120000 packages/analytics/.agents/skills create mode 120000 packages/analytics/.claude/CLAUDE.md create mode 120000 packages/analytics/.claude/skills create mode 100644 packages/analytics/.gitignore create mode 100644 packages/analytics/AGENTS.md create mode 100644 packages/analytics/README.md create mode 100644 packages/analytics/emdash-plugin.jsonc create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/skills/creating-plugins/SKILL.md create mode 100644 packages/analytics/src/astro/index.ts create mode 100644 packages/analytics/src/i18n.ts create mode 100644 packages/analytics/src/index/bootstrap.ts create mode 100644 packages/analytics/src/index/entries.ts create mode 100644 packages/analytics/src/index/paths.ts create mode 100644 packages/analytics/src/plugin.ts create mode 100644 packages/analytics/src/providers/cloudflare.ts create mode 100644 packages/analytics/src/providers/demo.ts create mode 100644 packages/analytics/src/providers/types.ts create mode 100644 packages/analytics/src/settings.ts create mode 100644 packages/analytics/src/store/access.ts create mode 100644 packages/analytics/src/store/rows.ts create mode 100644 packages/analytics/src/store/views.ts create mode 100644 packages/analytics/src/sync/older.ts create mode 100644 packages/analytics/src/sync/scheduler.ts create mode 100644 packages/analytics/src/sync/window.ts create mode 100644 packages/analytics/src/ui/blocks.ts create mode 100644 packages/analytics/src/ui/content.ts create mode 100644 packages/analytics/src/ui/format.ts create mode 100644 packages/analytics/src/ui/page.ts create mode 100644 packages/analytics/src/ui/panel.ts create mode 100644 packages/analytics/src/ui/setup.ts create mode 100644 packages/analytics/src/ui/status.ts create mode 100644 packages/analytics/src/ui/widget.ts create mode 100644 packages/analytics/tests/astro.test.ts create mode 100644 packages/analytics/tests/backfill.test.ts create mode 100644 packages/analytics/tests/bridge-calls.ts create mode 100644 packages/analytics/tests/budget.test.ts create mode 100644 packages/analytics/tests/cloudflare.test.ts create mode 100644 packages/analytics/tests/content-sync.test.ts create mode 100644 packages/analytics/tests/content.test.ts create mode 100644 packages/analytics/tests/demo-sync.test.ts create mode 100644 packages/analytics/tests/demo.test.ts create mode 100644 packages/analytics/tests/errors.test.ts create mode 100644 packages/analytics/tests/host.ts create mode 100644 packages/analytics/tests/i18n.test.ts create mode 100644 packages/analytics/tests/page-sync.test.ts create mode 100644 packages/analytics/tests/page.test.ts create mode 100644 packages/analytics/tests/panel.test.ts create mode 100644 packages/analytics/tests/paths.test.ts create mode 100644 packages/analytics/tests/plugin.test.ts create mode 100644 packages/analytics/tests/rows.test.ts create mode 100644 packages/analytics/tests/setup-sync.test.ts create mode 100644 packages/analytics/tests/setup.test.ts create mode 100644 packages/analytics/tests/views-sync.test.ts create mode 100644 packages/analytics/tests/views.test.ts create mode 100644 packages/analytics/tests/widget.test.ts create mode 100644 packages/analytics/tests/window.test.ts create mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/analytics/tsdown.config.ts create mode 100644 packages/analytics/vitest.config.ts create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/roles.ts create mode 100644 packages/shared/src/sweep.ts create mode 100644 packages/shared/src/values.ts create mode 100644 packages/shared/tests/roles.test.ts create mode 100644 packages/shared/tests/sweep.test.ts create mode 100644 packages/shared/tests/values.test.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100755 scripts/compat-matrix.sh diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..645c953 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +Every change to a published package needs a changeset: `pnpm changeset`, +pick the package and the bump, and write what someone upgrading needs to +know. The text lands verbatim in the package's CHANGELOG. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..41b4b46 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json", + "baseBranch": "main", + "access": "public", + "ignore": [], + "fixed": [], + "linked": [], + "format": "auto", + "updateInternalDependencies": "patch", + "privatePackages": { "version": false, "tag": false }, + "commit": false, + "changelog": "@changesets/cli/changelog" +} diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml new file mode 100644 index 0000000..73c6bf6 --- /dev/null +++ b/.github/workflows/compat-matrix.yml @@ -0,0 +1,65 @@ +# Runs every plugin suite against every EmDash version we claim to support. +# +# Deliberately NOT named emdash-release.yml: `emdash-plugin release setup` +# generates that file, and this must not collide with it. +# +# Two jobs, because they answer different questions: +# +# declared gating. The versions inside our published range. A failure here +# is our regression and blocks the branch. +# latest informational, never blocks. Runs against whatever EmDash is +# current on npm. EmDash ships a minor every 6-8 days and any minor +# may carry breaking changes, so this is the signal to widen the +# tested-up-to tables in the package READMEs -- or not to. +name: compat matrix + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + schedule: + # Monday 06:00 UTC. EmDash releases land mid-week, so this reports on the + # previous week's minor before the next one arrives. + - cron: "0 6 * * 1" + +permissions: + contents: read + +jobs: + declared: + name: declared range + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + # The whole point of committing .nvmrc: one file decides the Node + # version here, on a laptop via fnm, and on Cloudflare Workers Builds. + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: ./scripts/compat-matrix.sh 0.39.1 0.40.0 + + latest: + name: current EmDash (informational) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - id: resolve + # dist-tags.latest, not the highest semver: emdash@1.0.0 exists on npm + # but was published 2026-04-27 and deprecated, and it outranks every + # real 0.x release in semver order. + run: echo "version=$(npm view emdash dist-tags.latest)" >> "$GITHUB_OUTPUT" + - run: | + echo "current EmDash on npm: ${{ steps.resolve.outputs.version }}" + ./scripts/compat-matrix.sh "${{ steps.resolve.outputs.version }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a596a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +.DS_Store +.emdash/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bab9cca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# emdash-plugins + +EmDash CMS plugins by Eisbachcode. A pnpm workspace: one published package +per directory under `packages/`, plus `packages/shared`, which is internal, +never published, and inlined into each plugin's build. + +`CLAUDE.md` is a symlink to this file. + +This file covers what is true across the workspace. Each plugin has its own +`AGENTS.md` and a `skills/creating-plugins/SKILL.md` generated by +`emdash-plugin init`; read both before editing that plugin. + +## New packages + +Scaffold with `emdash-plugin init ` inside `packages/`, then move +source in. Do not copy one package to start the next. Two things never come +across from a generated package: its nested `pnpm-workspace.yaml`, which +would declare a second workspace root, and its open-ended `"emdash": "*"` +peer (see below). + +## Conventions + +- Tabs. English in code, comments and docs. +- ESM: internal imports carry `.js`; `import type` for types. +- The `emdash` peer range has a floor and no ceiling, and the floor matches + `release.requires["env:emdash"]` in the manifest. Compatibility with later + EmDash releases is checked by `scripts/compat-matrix.sh`, not promised by + the range. +- Read EmDash APIs from the published release or from `upstream/main`, and + say which. A fork's `main` drifts behind without anyone noticing. +- Anything that talks HTTP takes an injected `fetch`. Whole sync ticks go + through the test host's `host.http.respond()`. +- A sandboxed invocation gets ten subrequests and every `ctx` call is one. + Plugins with a budget test count each invocation's worst case there; run + it after any change that adds a `ctx` call. +- A test must be able to fail on a real regression. Do not assert a config + literal back at itself or restate the implementation. + +## Checks + +```sh +pnpm install +pnpm typecheck +pnpm test # emdash-plugin validate, then vitest, per package +pnpm build +./scripts/compat-matrix.sh 0.39.1 0.40.0 # suites against other EmDash releases +``` + +## Releases + +Versions come from changesets: add one with `pnpm changeset` for every +change to a published package, written for someone upgrading. The workspace +installs with pnpm 11; `allowBuilds` in `pnpm-workspace.yaml` lets esbuild +and workerd run their install scripts, which the test host needs. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 2dfcf25..9901b68 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,19 @@ Plugins for [EmDash](https://github.com/emdash-cms/emdash) by [Eisbachcode](https://eisbachcode.de). -No plugins are published here yet. The first one, web analytics, moves in once it has run on a live site. +| Package | What it does | +|---|---| +| [`@eisbachcode/emdash-plugin-analytics`](packages/analytics) | Cloudflare Web Analytics on the EmDash dashboard and next to your content: traffic per entry, a setup check, German and English | + +## Development + +A pnpm workspace. See [AGENTS.md](AGENTS.md) for conventions and checks. + +```sh +pnpm install +pnpm test +``` + +## Licence + +MIT, see [LICENSE](LICENSE). diff --git a/package.json b/package.json new file mode 100644 index 0000000..b422d2f --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "@eisbachcode/emdash-plugins", + "private": true, + "type": "module", + "description": "EmDash plugins by Eisbachcode", + "scripts": { + "build": "pnpm run --filter \"./packages/*\" build", + "test": "pnpm run --filter \"./packages/*\" test", + "typecheck": "pnpm run --filter \"./packages/*\" typecheck", + "changeset": "changeset", + "version-packages": "changeset version", + "release": "pnpm build && changeset publish" + }, + "packageManager": "pnpm@11.15.1", + "license": "MIT", + "devDependencies": { + "@changesets/cli": "^3.0.3" + } +} diff --git a/packages/analytics/.agents/skills b/packages/analytics/.agents/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/analytics/.agents/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/analytics/.claude/CLAUDE.md b/packages/analytics/.claude/CLAUDE.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/packages/analytics/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/packages/analytics/.claude/skills b/packages/analytics/.claude/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/analytics/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/analytics/.gitignore b/packages/analytics/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/analytics/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/analytics/AGENTS.md b/packages/analytics/AGENTS.md new file mode 100644 index 0000000..f32dcb7 --- /dev/null +++ b/packages/analytics/AGENTS.md @@ -0,0 +1,50 @@ +# Agent instructions + +Before editing this plugin, read `skills/creating-plugins/SKILL.md` completely. Codex discovers the same directory through `.agents/skills`; Claude discovers it through `.claude/skills` and reads these instructions through `.claude/CLAUDE.md`. +Keep `emdash-plugin.jsonc` aligned with the runtime implementation, declare every capability and host the plugin uses, and run the generated validation, typecheck, test, and build scripts after changes. + +## Toolchain + +`emdash` is a peer with a floor and no ceiling (`>=0.39.0`, the same as +`env:emdash` in the manifest); the dev dependency stays below the next +major until the toolchain is moved on purpose. Built with +`@emdash-cms/plugin-cli@0.12.0`, `@emdash-cms/plugin-test@0.2.0` and +`@emdash-cms/blocks@0.39.0`; `scripts/compat-matrix.sh` at the repository +root runs the suite against later EmDash releases. A plain `pnpm install` +at the workspace root is enough. + +## Two builds + +`pnpm run build` runs both: + +1. `emdash-plugin build` — the sandbox runtime and the descriptor module. Its + entry is hard-coded to `src/plugin.ts`. +2. `tsdown` — the `./astro` export. It is a separate build on purpose and is + never part of the registry bundle, which stages only `backend.js`, + `manifest.json`, the README and images. + +## Things that will bite + +- **Never widen a provider query window.** `date_geq` older than + `today − 7` makes Cloudflare serve the whole query from a ~10 % sample, + quantizing every day in it and dropping quiet days entirely. There are + three layers guarding this (`syncWindow` clamps, the adapter refuses, + `decideWrite` will not downgrade). Do not remove one because the other two + exist. +- **Read before write.** Blind upserts cost roughly 5 D1 rows each and blow + through the free plan's daily budget at a 15-minute cadence. +- **Count your bridge calls.** A sandboxed invocation gets ten subrequests + and every `ctx` call spends one, `log` and `cron` included. The sync tick + alternates phases and Refresh only schedules a tick for exactly this + reason. `tests/budget.test.ts` counts each invocation's worst case; run it + after any change that adds a `ctx` call. +- **Block Kit keys are snake_case.** Use the constructors in + `src/ui/blocks.ts`; the renderer silently ignores camelCase. +- `routeCtx.ui` (locale, direction) reaches the widget and the pages only + when the plugin runs sandboxed. Registered in `plugins: []` it is + undefined there, so read `routeCtx.ui?.locale` and fall back to English; + never return empty blocks when it is missing. The editor panel gets it in + both modes, with `ui.entry`. +- **Block Kit keeps no state.** Anything a page needs to remember between + interactions travels in an `action_id`, a button `value` or a table + cursor (see `src/ui/content.ts`). diff --git a/packages/analytics/README.md b/packages/analytics/README.md new file mode 100644 index 0000000..35a7b95 --- /dev/null +++ b/packages/analytics/README.md @@ -0,0 +1,298 @@ +# @eisbachcode/emdash-plugin-analytics + +Cloudflare Web Analytics on the EmDash dashboard, and next to your content. + +Most analytics plugins either inject a script or show site-wide numbers. +This one keeps a `path → entry` index, so views can be attributed to the +entry that earned them rather than to a URL string. + +> **Status: first release.** Cloudflare Web Analytics plus demo data: a +> dashboard widget, an Analytics page with a setup check, a per-entry page +> and an Analytics panel in the entry editor. Tested on EmDash 0.39 and 0.40. +> See "Not in this version". + +## The Analytics page + +**Plugins → Analytics** in the admin sidebar, or "Open analytics" on the +dashboard widget. Editors and admins see it. + +The page asks the provider live on every load, so it has numbers from the +first minute after setup; it does not wait for the sync. It only ever asks +for the days the provider still counts exactly (for Cloudflare, the last +7 days and today); older days come from the plugin's own store. On a new +install a 30- or 90-day range therefore starts at the first exact day and +says so, instead of showing Cloudflare's sampled figures, which on a +small site can be off by a factor of ten. If the live request fails, the +page shows the store alone. + +- Range: last 7, 30 or 90 days. Visits and page views with the change + against the previous period of the same length, shown only once stored + history reaches back that far. +- A daily chart of page views and visits. Days the provider reported + nothing are left out rather than drawn as zero. +- Top entries with path, and title and collection once the plugin has + matched the page to an entry. +- Referrers and countries, read live for the days the provider counts + exactly (the last week on Cloudflare), so on a 30- or 90-day range they + cover the last week and say so. +- "Open in Cloudflare" goes to this site's view in the Cloudflare dashboard. + +## Check setup + +**Check setup** on the Analytics page runs every check the numbers depend +on, in the order a fix has to happen: data source, API token and account, +Cloudflare access, site tag, hostnames, site URL, content index, scheduled +sync and last sync. Each problem gets one sentence that names the fix. + +Most setup mistakes do not fail, they produce zeroes: a beacon token pasted +as the site tag, a hostname filter that leaves out the host Cloudflare +reports, a site without a stored URL, a Worker without a Cron Trigger. None +of them raises an error the sync could report, which is why the check +exists. When the site tag or the hostname filter is wrong, it lists the +site tags on the account with their hostnames and the last week's page +views, so the right one can be copied into the settings. + +The check only reads; values are entered in the plugin's settings form. It +makes one request to Cloudflare. + +## Analytics per entry + +**Plugins → Analytics per entry**, or "Per entry" on the Analytics page. +Every published entry with its page views over the last 7 and 30 days, +today included, by collection or across all of them. Click a column header +to sort; sorting ascending puts the entries nobody visited first, which +Cloudflare's own dashboard cannot show, because it only knows paths that +got traffic. The publish date says when a small number just means a young +entry. + +On a site with translations, each row also shows the 30-day total of all +its languages, and **Languages combined** shows one row per translated +piece of content with each language's share. That mode groups the first +300 entries in the chosen order and says so when there are more. + +How the numbers are made: the last eight days come from the provider on +every sync, exactly. The 22 days before them come from the plugin's own +store, summed once a day, because Cloudflare only answers for older days +from a sample. So the 30-day figures cover 30 days only once the plugin has +been running for about three weeks, and the page says where they start +until then. + +**Rebuild index** walks the site's content again. The plugin keeps its +page-to-entry index current from the content hooks and catches up on +existing content by itself; a rebuild is for a changed URL pattern or site +URL. + +## The editor panel + +In the entry editor, the **Analytics** panel shows that entry's 7- and 30-day +page views and the path they are counted at, and on a translated entry +each language's numbers and their total. An entry that is not published +at a public URL says so instead. + +## What you need + +| | | +|---|---| +| EmDash | `>=0.39.0` — the plugin uses the content-join APIs (`ctx.schema`, `getPublicUrl`, `ctx.settings`) added in that release | +| A Cloudflare account | with Web Analytics enabled for the site | +| An API token | permission **Account → Account Analytics → Read** | +| `EMDASH_ENCRYPTION_KEY` | set on the site, or the API token cannot be stored (`npx emdash secrets generate`) | +| Scheduled tasks | the sync runs as a plugin cron task. On Cloudflare Workers that needs the Cron Trigger and `scheduled` handler from EmDash's Cloudflare deployment guide; on Node, EmDash runs them itself | + +### The token scope, precisely + +Create a **custom token** with exactly one permission row: + +``` +Account | Account Analytics | Read +``` + +**Not Zone.** A token carrying *Zone* Analytics Read authenticates fine and +answers zone queries happily, then fails every account query with +`not authorized for that account`. The RUM dataset is account-scoped only. +The plugin recognises that specific error and says so. + +Nothing else is needed. In particular the plugin does **not** ask for +Account Settings Read: the obvious endpoint for listing your Web Analytics +sites (`/accounts/{id}/rum/site_info/list`) requires it, so the plugin finds +your sites through the analytics data instead. + +Prefer an **account-owned** token over a user-owned one. A user token dies +with that user's membership; an account-owned token does not. + +## Settings + +Fill these in under Plugins → Analytics → Settings. + +| Setting | Notes | +|---|---| +| Data source | Cloudflare Web Analytics, or **demo data**: generated numbers that need no account (see below) | +| Cloudflare API token | Stored encrypted (AES-GCM). Needs `EMDASH_ENCRYPTION_KEY`; without it saving fails rather than storing plaintext | +| Cloudflare account ID | Manage Account → Account Home | +| Web Analytics site tag | **The site tag, not the beacon token.** They are different values. In the Cloudflare dashboard it is the ID at the end of the site's **Manage site** link (`…/web-analytics/edit/`); the analytics view does not show it. Leave empty and the widget lists the sites on the account that had traffic in the last 30 days, with their hostnames, so you can copy the right one | +| Hostnames to count | Comma-separated. Empty uses the site URL and its `www` form. One site tag often also covers `*.pages.dev` preview deploys, which should not be counted as production traffic | +| Sync every | 15 minutes by default | +| Keep daily rows for | 90 days by default | +| Paths per sync tick | 36 by default and at most: the most one tick can handle within a sandboxed invocation's ten subrequests. Lower it to write fewer D1 rows per day | + +### Trying it without Cloudflare + +Set **Data source** to demo data and press Refresh on the dashboard widget. +Refresh schedules a sync rather than running one, so the numbers appear +after the next scheduled run: at once on Node, with the site's next Cron +Trigger on Cloudflare. The plugin generates 90 days of plausible traffic, spread over your real +entries plus two paths that are no entry, and runs it through the same sync, +storage and path-to-entry join as real data. The widget says "Demo data, not +real traffic" while it is on. Switching the data source back clears every +stored number, so demo figures never mix with real ones. + +### site tag vs site token + +Two different values on the same Cloudflare site: + +- **site tag** — what the reporting API filters on. This plugin needs it. +- **site token** — what goes in the beacon snippet. The Astro integration + below needs it. + +Pasting the beacon token as the site tag returns no data at all, silently. + +## The beacon + +If your site already emits the Cloudflare beacon, skip this. That includes Cloudflare's automatic setup, which injects the beacon into Worker responses on a proxied custom domain too. If you set up the site with "exclude EU visitors", European visitors never get the beacon and your numbers will look close to zero. Either way, the plugin leaves `/_emdash` page views out of every number it reads, so automatic setup counting your admin sessions does not skew the figures. + +```js +// astro.config.mjs +import { analyticsBeacon } from "@eisbachcode/emdash-plugin-analytics/astro"; + +export default defineConfig({ + integrations: [ + analyticsBeacon({ + token: process.env.CF_WEB_ANALYTICS_TOKEN, // the site TOKEN + productionFlagEnv: "IS_PRODUCTION", // optional + }), + ], +}); +``` + +It injects at Astro's `head-inline` stage, which costs **zero database +queries**. The plugin registers no page hook at all, so your logged-out +request count does not change. + +Three things it does on purpose: + +- **Skips `/_emdash`.** The admin is an Astro page with its own ``, so + an injected beacon would count editor sessions and put `/_emdash/…` paths + in your top pages. +- **Skips `astro dev`.** A dev server reports under the same site tag as + production. Pass `includeDev: true` if you want it. +- **Warns instead of no-op'ing** when the token is missing, because a site + that quietly collects nothing looks fine for weeks. + +Consent gating, if you need it: + +```js +analyticsBeacon({ + token: "…", + consent: { event: "emdash:consent:analytics", grantedFlag: "analyticsOk" }, +}); +``` + +The vendor script is appended only once the event fires. Cloudflare Web +Analytics is cookieless and stores no identifiers, which several vendors +treat as "no banner required" — under TDDDG §25 in Germany that is not a +settled position. This integration gives you the gate; the decision is +yours and this is not legal advice. + +CSP, if your site sets one: `script-src static.cloudflareinsights.com`, +`connect-src cloudflareinsights.com`. + +## What the numbers mean + +**Cloudflare keeps unsampled beacon data for seven days.** Beyond that it +serves a roughly 10 % aggregate. Measured on a live account: + +| Query start | Reported page views | Sample interval | +|---|---|---| +| 7 days back | 31 | 1.35 | +| 8 days back | 10 | 10 | + +Past that boundary every value is a multiple of ten, and days too quiet to +survive the sample vanish from the response entirely. + +So this plugin never asks for a window starting more than seven days back, +and never overwrites an exact stored row with an estimate. **After the +first week its own store holds numbers the Cloudflare dashboard can no +longer reproduce.** The consequence to know about: the first week after +installing shows less history than you might expect, because there is no +exact history to import. Anything the plugin does label as estimated says +so in the widget. + +Two more honest caveats: + +- **Today is always provisional.** Even a single-day query for today comes + back sampled, so today's row keeps moving and is labelled accordingly. +- **Days are UTC.** The API has no timezone concept; the Cloudflare + dashboard renders in yours. Set the dashboard to UTC before comparing. + +## Who sees the numbers + +The widget and both pages declare `plugins:read`, which is **editor and +above**. + +The editor panel declares `content:edit_own`, and EmDash also checks that +the user may edit that entry: **authors see their own entries' numbers, +editors and admins every entry's**, contributors none. + +Authors and contributors will still see the widget card in the dashboard +and get a permission error inside it — EmDash renders every declared widget +for every role and gates on dispatch. That is an upstream gap, not +something this plugin can fix. + +## Languages + +The widget, both pages, the editor panel, toasts and error messages come in +English and German, following the administrator's admin language. Any other admin +language gets English. Number, date and country names follow the same +language, so a German admin reads "1.234", "18.09.2026" and "Deutschland". + +Two limits come from EmDash, not from this plugin: + +- **Registered in `plugins: []`, the plugin stays English** on the + dashboard and its pages. EmDash 0.39 tells a plugin the admin language + there only when it runs sandboxed from the registry. The editor panel + gets it either way. +- **Labels declared in the manifest stay English everywhere:** the sidebar + entry, the widget title and the settings form. EmDash renders those + itself and does not read plugin translations. + +## Privacy and data residency + +Cloudflare's four `rum*` datasets are **US-only**. An EU metadata boundary +cannot hold them, and the default configuration excludes EU visitor data +unless you flip it. If a customer has written "EU storage" into their +requirements, this provider does not meet it — that is a reason to use a +different provider, not a plugin bug. + +What leaves your site: path strings and a site tag, to +`api.cloudflare.com`. Nothing from your content. + +One account-model caveat: the token is account-scoped and `siteTag` is just +a filter. If several client sites share one Cloudflare account, a token +given to one site's CMS can read every site's analytics in that account. +One account per site owner, or accept and document it. + +## Not in this version + +- Referrers and countries beyond the last week. The plugin stores them per + sync, not per day, so they cannot be summed over a longer range. +- A second provider. The `Provider` interface is in place for Plausible, + which is also the answer for EU-storage requirements. +- A Views column in the content list. EmDash lets only native admin code + add one, which a sandboxed plugin cannot ship. +- Large sites catch up slowly. Every sync step fits EmDash's sandbox limit + of ten calls, so the index walk, the daily 30-day sum and the + per-path sync each handle a few dozen entries per tick. + +## Licence + +MIT. diff --git a/packages/analytics/emdash-plugin.jsonc b/packages/analytics/emdash-plugin.jsonc new file mode 100644 index 0000000..73b7a53 --- /dev/null +++ b/packages/analytics/emdash-plugin.jsonc @@ -0,0 +1,149 @@ +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "analytics", + "publisher": "did:plc:iucwrgdd464ddkranaa6ux3l", + + "license": "MIT", + "author": { "name": "Eisbachcode", "url": "https://eisbachcode.de", "email": "daniel@eisbachcode.de" }, + "security": { "email": "daniel@eisbachcode.de" }, + "repo": "https://github.com/eisbachcode/emdash-plugins", + + // The only version check a user meets before installing. EmDash refuses + // an install whose host falls outside this range, so it carries a floor + // and deliberately no ceiling: EmDash ships a minor every 6-8 days, and + // a ceiling would block correct installs every week rather than catch a + // break. The manifest has no advisory "tested up to" field to express + // caution without blocking, so the tested range belongs in the README. + "release": { + "requires": { "env:emdash": ">=0.39.0" } + }, + // Registry search indexes slug and description and has no keywords + // field, so every provider this plugin supports has to be named here. + "description": "Cloudflare Web Analytics on the EmDash dashboard and next to your content: visits, page views, top pages, referrers and countries, with per-entry performance mapped back to the entries that produced it.", + "keywords": ["analytics", "cloudflare", "dashboard", "widget", "content"], + + // Trust contract. + // + // `content:read` and `schema:read` are the content join: the plugin + // asks the schema which collections are routable, asks the content API + // where each entry is published, and stores that mapping. `network:request` + // talks to exactly one pinned host. + // + // Deliberately absent: `content:write` (this plugin reports, it never + // edits), `users:read`, `email:send`. Nothing about your content leaves + // the site — the outbound request carries path strings and a site tag. + "capabilities": ["content:read", "schema:read", "network:request"], + "allowedHosts": ["api.cloudflare.com"], + + "storage": { + // The fact table: one row per (day, path). + // + // Rows freeze once their day is closed and was read from an + // unsampled response, because after seven days Cloudflare can no + // longer reproduce them — see src/sync/window.ts. + "daily": { "indexes": ["date", "path"] }, + + // Site totals per day, read straight into the widget's chart. + "rollup": { "indexes": ["date"] }, + + // The path -> entry join, maintained forward from the content hooks. + // + // `views30` and `views7` are indexed because the content page sorts + // by them and storage rejects an orderBy on an undeclared index. + // Every index costs one more D1 row per write, which is why the + // sync does read-compare-write instead of blind upserts. + "entries": { + "indexes": ["path", "collection", "entryId", "translationGroup", "views30", "views7"] + } + }, + + "admin": { + // The only settings surface. The generated form is the + // platform-standard place for credentials and cannot be suppressed, + // so the plugin does not add a second one that would drift from it. + // + // `secret` is what triggers AES-GCM encryption with + // EMDASH_ENCRYPTION_KEY. Without that key set, saving throws + // PLUGIN_SETTING_ENCRYPTION_KEY_MISSING rather than storing + // plaintext — the widget says so in plain words. + "settingsSchema": { + "provider": { + "type": "select", + "label": "Data source", + "default": "cloudflare", + "options": [ + { "value": "cloudflare", "label": "Cloudflare Web Analytics" }, + { "value": "demo", "label": "Demo data (generated, not real traffic)" } + ], + "description": "Demo data needs no account and fills the dashboard with generated numbers. Switching the data source clears the stored numbers." + }, + "cfApiToken": { + "type": "secret", + "label": "Cloudflare API token", + "description": "Permission: Account → Account Analytics → Read. A Zone-level Analytics token authenticates but cannot read this dataset." + }, + "cfAccountId": { + "type": "string", + "label": "Cloudflare account ID", + "description": "Found under Manage Account → Account Home in the Cloudflare dashboard." + }, + "cfSiteTag": { + "type": "string", + "label": "Web Analytics site tag", + "description": "The site tag, not the beacon token: they are different values. In the Cloudflare dashboard it is the ID at the end of the site's Manage site link (web-analytics/edit/). Leave empty and the widget lists the sites on the account that have traffic." + }, + "hosts": { + "type": "string", + "label": "Hostnames to count", + "description": "Comma-separated. Leave empty to use the site URL and its www form. One site tag often also covers preview domains, which should not be counted." + }, + "syncInterval": { + "type": "select", + "label": "Sync every", + "default": "*/15 * * * *", + "options": [ + { "value": "*/15 * * * *", "label": "15 minutes" }, + { "value": "*/30 * * * *", "label": "30 minutes" }, + { "value": "0 * * * *", "label": "Hour" }, + { "value": "0 */6 * * *", "label": "6 hours" } + ] + }, + "retentionDays": { + "type": "number", + "label": "Keep daily rows for", + "description": "Days. Cloudflare itself answers for 184 days; beyond that this store is the only copy.", + "default": 90, + "min": 7, + "max": 184 + }, + "chunkSize": { + "type": "number", + "label": "Paths per sync tick", + "description": "Lower this if the site runs on D1's free plan; every path written costs several D1 rows.", + "default": 36, + "min": 10, + "max": 36 + } + }, + + "pages": [ + { "path": "/analytics", "label": "Analytics", "icon": "chart-line" }, + { "path": "/analytics/content", "label": "Analytics per entry", "icon": "chart" } + ], + "widgets": [{ "id": "traffic", "title": "Traffic", "size": "half" }], + + // Opens beside the entry editor, in every collection: the plugin + // cannot know at build time which collections a site routes, and + // an entry without a page says so in the panel. + // + // "Analytics" because manifest strings cannot be translated, and it + // reads the same in English and German; "Views" sat in English above + // a German panel. + "editorPanels": [{ "id": "views", "title": "Analytics", "route": "panel" }] + } + + // No `hooks` and no `routes` here: the authored manifest is strict and + // rejects both. They live in src/plugin.ts and the build probes them + // into the wire manifest. +} diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 0000000..24e58b4 --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,71 @@ +{ + "name": "@eisbachcode/emdash-plugin-analytics", + "version": "0.1.0", + "description": "EmDash plugin: Cloudflare Web Analytics on the dashboard and next to your content.", + "keywords": [ + "emdash", + "emdash-plugin", + "cms", + "plugin", + "analytics", + "cloudflare", + "dashboard" + ], + "license": "MIT", + "author": "Eisbachcode ", + "homepage": "https://github.com/eisbachcode/emdash-plugins/tree/main/packages/analytics#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/eisbachcode/emdash-plugins.git", + "directory": "packages/analytics" + }, + "bugs": "https://github.com/eisbachcode/emdash-plugins/issues", + "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs", + "./astro": { + "import": "./dist/astro/index.mjs", + "types": "./dist/astro/index.d.mts" + } + }, + "files": [ + "dist", + "emdash-plugin.jsonc" + ], + "scripts": { + "validate": "emdash-plugin validate", + "build": "emdash-plugin build && tsdown", + "dev": "emdash-plugin dev", + "typecheck": "tsc --noEmit", + "test": "emdash-plugin validate && vitest run", + "bundle": "emdash-plugin bundle", + "login": "emdash-plugin login", + "publish": "emdash-plugin publish", + "release:setup": "emdash-plugin release setup", + "build:astro": "tsdown" + }, + "engines": { + "node": ">=22.16" + }, + "peerDependencies": { + "emdash": ">=0.39.0" + }, + "devDependencies": { + "@eisbachcode/emdash-plugin-shared": "workspace:*", + "@emdash-cms/blocks": "0.39.0", + "@emdash-cms/plugin-cli": "0.12.0", + "@emdash-cms/plugin-test": "0.2.0", + "emdash": ">=0.39.0 <1.0.0", + "tsdown": "0.20.3", + "typescript": "^5.9.0", + "vitest": "^4.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/analytics/skills/creating-plugins/SKILL.md b/packages/analytics/skills/creating-plugins/SKILL.md new file mode 100644 index 0000000..336829d --- /dev/null +++ b/packages/analytics/skills/creating-plugins/SKILL.md @@ -0,0 +1,29 @@ +--- +name: creating-plugins +description: Build, test, and publish this sandboxed EmDash plugin. Use for changes to emdash-plugin.jsonc, src/plugin.ts, hooks, routes, capabilities, storage, Block Kit admin UI, bundling, or releases. +--- + +# Creating EmDash plugins + +Read `emdash-plugin.jsonc` and `src/plugin.ts` before editing. The manifest is the identity and trust contract; the source contains runtime hooks and routes. + +## Runtime rules + +- Assign the runtime definition to a `SandboxedPlugin`-typed constant and export it as default from `src/plugin.ts`. +- Use Web APIs. Do not import Node.js built-ins into plugin runtime code. +- Declare every runtime API in `capabilities` and every network destination in `allowedHosts`. +- Use `ctx.storage` for queryable records and `ctx.kv` for key-value state. +- Use Block Kit for sandboxed admin UI. Do not ship browser React components. +- Treat public routes as internet-facing and validate their inputs. + +## Validation + +Use the package scripts in this repository. The test script builds the plugin and runs it inside workerd through EmDash's production sandbox wrapper and host bridge. Use `createPluginTestHost()` to invoke hooks and routes, create content fixtures, and inspect plugin KV or declared storage. Dispose the host after each test so its bindings reset. + +Before handing off a change, run validation, typecheck, tests, and build. A release also requires a version bump in `package.json` when runtime behavior or the trust contract changes. + +## Publishing + +Use the local publish script for a release started from this computer. Use the release-setup script for GitHub Actions. Setup detects a root Changesets configuration and offers to follow packages released by Changesets; otherwise it uses package tags. Connect the generated reusable workflow to the existing Changesets publish job by passing its published-package output. Changesets Action v1 names the step output `publishedPackages`; v2 names it `published-packages`. Expose it as a `published-packages` job output and pass it to the generated workflow from a dependent job when Changesets reports `published == 'true'`. The first automated release connects the repository workflow; later packages reuse it only when their signed profiles name the same repository. + +For complete EmDash patterns and API details, use https://docs.emdashcms.com/plugins/creating-plugins/. diff --git a/packages/analytics/src/astro/index.ts b/packages/analytics/src/astro/index.ts new file mode 100644 index 0000000..b6f9f09 --- /dev/null +++ b/packages/analytics/src/astro/index.ts @@ -0,0 +1,205 @@ +/** + * The beacon, injected at build time. + * + * Not a `page:fragments` hook, deliberately. The documented EmDash example + * for analytics uses that hook and calls `ctx.settings.get()` inside the + * handler, which is one uncached `SELECT` per render — and the handler can + * run three times on a content page with an SEO panel, because + * `resolveSeoPanelPage()` returns a fresh context object and the only memo + * is a `WeakMap` keyed on that object. A baseline anonymous `GET /` is ten + * queries; that example adds one to three of them forever, on every + * anonymous request. Four of the thirteen shipped templates also render no + * body placements at all, so `body:end` silently does nothing there. + * + * `injectScript("head-inline")` costs zero queries, emits verbatim into + * ``, and is the stage every maintained Astro analytics integration + * uses. The plugin registers no page hook whatsoever, which is what keeps + * `pnpm query-counts` flat. + */ + +export type BeaconProvider = "cloudflare"; + +export interface AnalyticsBeaconOptions { + /** Only Cloudflare in v1; named so the option does not have to change later. */ + provider?: BeaconProvider; + /** + * The Cloudflare Web Analytics **site token** — the `token` value in the + * beacon snippet. + * + * This is not the site tag. They are different values, and the CMS side + * of this plugin needs the *tag*. Confirmed against the live API on + * 2026-09-20: every site on the account reports two different values. + */ + token?: string; + /** + * Cloudflare has auto-tracked soft navigations since 2026-08-20, which + * is what an Astro site using `` wants. Set `false` to + * opt out; leaving it unset keeps Cloudflare's own default. + */ + spa?: boolean; + /** + * Gate the beacon behind a consent event. + * + * With this set the injected code registers a listener and appends the + * vendor script only once the event fires (or immediately if the + * documented flag says consent was already given). The vendor loader is + * never injected behind an ungranted gate — a "cookieless" claim is not + * a settled defence under TDDDG §25 in Germany, and this integration + * provides the gate rather than an opinion. + */ + consent?: { + /** Event dispatched on `window` once analytics consent is granted. */ + event: string; + /** + * Name of a global boolean or a function on `window` that reports + * consent already granted, checked once at load. + */ + grantedFlag?: string; + }; + /** + * Inject during `astro dev`. Off by default: a dev server reports under + * the same site tag as production and would pollute real numbers with + * localhost traffic. + */ + includeDev?: boolean; + /** + * Environment variable that must equal `"true"` for the beacon to be + * injected. Use it to keep preview deploys out of production numbers — + * one Cloudflare site tag routinely covers `*.pages.dev` and + * `*.workers.dev` hosts alongside the real domain. + */ + productionFlagEnv?: string; +} + +const BEACON_SRC = "https://static.cloudflareinsights.com/beacon.min.js"; + +/** The admin lives here and its pages have their own ``. */ +const ADMIN_PREFIX = "/_emdash"; + +/** + * Minimal shape of what Astro hands an integration, declared locally so + * this file needs no `astro` import at runtime. Astro's own types are + * structurally compatible. + */ +export interface AstroIntegrationLike { + name: string; + hooks: { + "astro:config:setup": (options: { + command: "dev" | "build" | "preview" | "sync"; + injectScript: (stage: "head-inline" | "before-hydration" | "page" | "page-ssr", content: string) => void; + logger: { warn: (message: string) => void; info: (message: string) => void }; + }) => void; + }; +} + +export function analyticsBeacon(options: AnalyticsBeaconOptions = {}): AstroIntegrationLike { + const provider = options.provider ?? "cloudflare"; + + return { + name: "@eisbachcode/emdash-plugin-analytics/astro", + hooks: { + "astro:config:setup": ({ command, injectScript, logger }) => { + if (provider !== "cloudflare") { + logger.warn(`Unknown analytics provider "${provider}"; no beacon injected.`); + return; + } + + // A missing token is named out loud rather than no-op'd: a + // silent no-op here is a site that collects nothing and looks + // fine, which is the failure mode nobody notices for weeks. + if (!options.token) { + logger.warn( + "analyticsBeacon(): no `token` given, so no beacon was injected. Pass the Cloudflare Web Analytics site token (the `token` value in the beacon snippet, not the site tag).", + ); + return; + } + + if (command === "dev" && !options.includeDev) { + logger.info("analyticsBeacon(): skipping injection under `astro dev`. Pass includeDev: true to override."); + return; + } + + if (options.productionFlagEnv) { + const value = readEnv(options.productionFlagEnv); + if (value !== "true") { + logger.info( + `analyticsBeacon(): ${options.productionFlagEnv} is not "true", so no beacon was injected.`, + ); + return; + } + } + + injectScript("head-inline", buildBeaconScript(options)); + }, + }, + }; +} + +/** + * The injected source. + * + * Exported so it can be tested as a string: this is the one piece of this + * plugin that runs in a visitor's browser, and it must never throw there. + */ +export function buildBeaconScript(options: AnalyticsBeaconOptions): string { + const beaconConfig: Record = { token: options.token }; + if (options.spa === false) beaconConfig.spa = false; + + // JSON.stringify twice: once for the attribute value Cloudflare parses, + // once so the result is a safe JavaScript string literal. `` + // inside an inline script would otherwise end the element early. + const configLiteral = JSON.stringify(JSON.stringify(beaconConfig)).replace(/, so without this every dashboard + // visit would be counted and /_emdash paths would show up in the + // top-pages table. + if (p === ${adminLiteral} || p.indexOf(${adminLiteral} + "/") === 0) return; + var loaded = false; + function load() { + if (loaded) return; + loaded = true;${inject} + }${gate} + } catch (e) {} +})();`; +} + +function readEnv(name: string): string | undefined { + const meta = import.meta as unknown as { env?: Record }; + const fromMeta = meta.env?.[name]; + if (fromMeta !== undefined) return fromMeta; + const proc = (globalThis as { process?: { env?: Record } }).process; + return proc?.env?.[name]; +} + +export default analyticsBeacon; diff --git a/packages/analytics/src/i18n.ts b/packages/analytics/src/i18n.ts new file mode 100644 index 0000000..442d9e8 --- /dev/null +++ b/packages/analytics/src/i18n.ts @@ -0,0 +1,439 @@ +/** + * The plugin's own message catalogue. + * + * Block Kit plugins localize themselves: the host passes the administrator's + * locale in `routeCtx.ui` and does not consume plugin catalogues. English is + * the source and the fallback for every locale without a catalogue here. + * Manifest strings (page label, widget title, settings form) stay static. + * + * `de` is typed against the English keys, so a missing German entry fails + * the build rather than showing an English sentence in a German admin. + */ + +type Plural = { one: string; other: string }; +type Message = string | Plural; + +const en = { + noAnalyticsYet: "No analytics yet", + visitsLastDays: "Visits, last {days} days", + pageviewsLastDays: "Page views, last {days} days", + pageviews: "Page views", + visits: "Visits", + colPage: "Page", + colPath: "Path", + colViews: "Views", + colVisits: "Visits", + colEntry: "Entry", + colCollection: "Collection", + colSource: "Source", + colCountry: "Country", + noPagesYet: "No pages recorded yet.", + nothingRecorded: "Nothing recorded yet.", + refresh: "Refresh", + openAnalytics: "Open analytics", + openInCloudflare: "Open in Cloudflare", + rangeDays: { one: "{count} day", other: "{count} days" }, + topEntries: "Top entries", + referrers: "Referrers", + countries: "Countries", + entriesEmpty: "No per-entry numbers yet. They appear once the sync has matched pages to entries.", + snapshotSince: "Referrers and countries since {date}, from the latest sync.", + historyStarts: "History starts on {date}", + coverageRange: "Per-entry numbers for the last {days} days.", + coveragePartial: "Per-entry numbers since {date}; older days in this range were not read.", + coverageMatched: "Per-entry numbers since {date}. Days before the plugin matched an entry are not counted per entry.", + coverageLive: "Per-entry numbers since {date}, as far back as the provider counts every page view.", + breakdownsSince: "Referrers and countries since {date}, as far back as the provider counts every page view.", + perEntry: "Per entry", + overview: "Overview", + + allCollections: "All", + modeEntries: "Each language", + modeCombined: "Languages combined", + firstPage: "First page", + rebuildIndex: "Rebuild index", + rebuildRequested: "Rebuilding the content index. It runs with the next syncs, a few entries at a time.", + col7Days: "7 days", + col30Days: "30 days", + colLanguage: "Language", + colAllLanguages: "All languages, 30 days", + colByLanguage: "By language, {days} days", + colPublished: "Published", + sortMostViewed: "Most viewed first, last {days} days.", + sortLeastViewed: "Least viewed first, last {days} days.", + showingRows: "Entries {from} to {to}.", + historyThirty: "30-day figures count from {date}, where the stored history starts.", + combinedPartial: "Languages combined across the first {count} entries in this order; entries after them are left out.", + indexingAt: "Indexing content: collection {at} of {total}. Entries appear as they are indexed.", + rebuildingAt: "Rebuilding the content index: collection {at} of {total}.", + noEntriesYet: "No entries yet", + noEntriesIndexing: "The content index is still being built. Entries appear as it progresses.", + noEntriesNoUrls: + "No published entry has a public URL. Check that the site URL is set in EmDash and that the collections have URL patterns.", + noEntriesHere: "No published entries here.", + panelNoPage: "No page for this entry yet", + panelNoPageDetail: "Views appear once the entry is published at a public URL and the next sync has run.", + panelNotPublished: "Not published at the moment; these are the numbers from when it was.", + panelCountedAt: "Counted at {path}", + panelAllLanguages: "All languages: {count} in 30 days", + + checkSetup: "Check setup", + checkAgain: "Check again", + backToAnalytics: "Back to analytics", + setupTitle: "Setup check", + setupAllGood: "Everything the numbers depend on is in place.", + setupProblems: { + one: "{count} problem stops or distorts the numbers.", + other: "{count} problems stop or distort the numbers.", + }, + colCheck: "Check", + colStatus: "Status", + colDetails: "Details", + statusOk: "OK", + statusProblem: "Problem", + statusWaiting: "Waiting", + statusSkipped: "Not checked", + checkSource: "Data source", + checkCredentials: "API token and account", + checkAccess: "Cloudflare access", + checkSiteTag: "Site tag", + checkHosts: "Hostnames", + checkSiteUrl: "Site URL", + checkIndex: "Content index", + checkScheduler: "Scheduled sync", + checkLastSync: "Last sync", + sourceCloudflare: "Cloudflare Web Analytics.", + sourceDemo: "Demo data: generated numbers. Nothing on Cloudflare is checked.", + credentialsSaved: "Saved.", + encryptionKeyHint: + "If saving the token fails with an encryption error, the site needs EMDASH_ENCRYPTION_KEY: generate a value with npx emdash secrets generate and store it as a secret (on Cloudflare: wrangler secret put EMDASH_ENCRYPTION_KEY).", + needsCredentials: "Needs the API token and the account ID.", + accessOk: "The token can read this account's Web Analytics.", + needsAccess: "Needs working Cloudflare access.", + siteTagMissing: "No site tag set. Copy one from the list below into Web Analytics site tag in the plugin's settings.", + siteTagFound: "{tag}: {count} page views in the last {days} days.", + siteTagNotFound: + "No traffic under this site tag in the last {days} days. The sites with traffic are listed below. The token in the beacon snippet is a different value from the site tag.", + accountNoTraffic: + "This account reported no Web Analytics traffic in the last {days} days. Check that the beacon is on the site.", + needsSiteTag: "Needs a site tag with traffic.", + hostsEvery: "No hostname filter: counting every hostname this site tag reports ({hosts}).", + hostsCounted: "Counted: {hosts}.", + hostsPartly: "Counted: {counted}. Not counted: {excluded}.", + hostsNone: + "Cloudflare reports this site tag under {reported}, but the plugin counts only {counted}. Change Hostnames to count in the plugin's settings.", + siteUrlMissing: + "EmDash has no site URL stored, so entries have no public address and page views cannot be matched to them. EmDash stores it (the emdash:site_url option) when the setup wizard runs on the live domain.", + needsSiteUrl: "Needs the site URL.", + indexMatched: { one: "{count} entry matched to its page.", other: "{count} entries matched to their pages." }, + schedulerNotScheduled: "The sync is not scheduled yet. Opening the dashboard schedules it.", + schedulerWaiting: "Scheduled every {interval}, not run yet. If it still has not run after {interval}, the site runs no scheduled tasks.", + schedulerOk: "Last run {age}, scheduled every {interval}.", + schedulerStale: "Last run {age}, but scheduled every {interval}: the site's scheduler is not running on time.", + schedulerNeverRan: "Scheduled {age} to run every {interval}, and it has never run: the site's scheduler is not running.", + schedulerRefreshStuck: "A sync requested {age} has not run: the site's scheduler is not running.", + schedulerHowTo: + "On Cloudflare Workers, EmDash runs scheduled tasks from a Cron Trigger and the scheduled handler in src/worker.ts. npx emdash doctor checks that both are configured.", + minutes: { one: "{count} minute", other: "{count} minutes" }, + hours: { one: "{count} hour", other: "{count} hours" }, + sitesTitle: "Sites on this account", + colSiteTag: "Site tag", + colHostnames: "Hostnames", + sitesNote: + "Sites with page views in the last {days} days. In the Cloudflare dashboard, the site tag is also the ID at the end of the site's Manage site link.", + + demoData: "Demo data, not real traffic", + synced: "Synced {age}", + notSynced: "Not synced yet", + estimatedCloudflare: "estimated (Cloudflare sampled this range)", + estimatedSampled: "estimated (sampled)", + todayCounting: "today is still counting", + nothingMatched: "no page is matched to an entry yet; Check setup on the Analytics page says why", + lastAttemptFailed: "Last attempt failed: {error}", + lastAttemptFailedAge: "Last attempt failed {age}: {error}", + firstSyncPending: "The first sync has not run yet. It is scheduled now; numbers appear after it completes.", + syncedNoViews: + "Synced {age}, but Cloudflare reported no page views for this site tag. Check that the beacon is on the site.", + recently: "recently", + + noEarlierPeriod: "no earlier period to compare yet", + noneEitherPeriod: "none in either period", + upFromNone: "up from none last period", + vsPrevious: "{change} vs previous period", + + syncRequested: "Sync requested. The numbers update with the next scheduled run.", + pageRefreshed: "Updated with the latest numbers.", + pageRefreshFailed: "The provider did not answer; showing the stored numbers.", + syncUnschedulable: "This site runs no scheduled tasks, so a sync cannot be requested.", + + notConfigured: "Analytics is not configured yet: add {parts} in the plugin's settings.", + partApiToken: "an API token with Account → Account Analytics → Read", + partAccountId: "the Cloudflare account ID", + noNetwork: "Analytics cannot reach the network: the network:request capability is not granted.", + noSiteTagSites: "No site tag set. Sites with traffic on this account: {sites}.", + noSiteTagNoTraffic: + "No site tag set, and this account reported no Web Analytics traffic in the last 30 days. Check that the beacon is on the site.", + indexingFailed: "Indexing content failed: {detail}", + summingFailed: "Summing the 30-day views failed: {detail}", + storageUnavailable: "Storage collections are not available.", + cfNoAccount: + "Cloudflare returned no account. Check the Account ID, and that the token's permission is Account → Account Analytics → Read.", + cfForbidden: "Cloudflare rejected the token (403). It needs Account → Account Analytics → Read.", + cfRateLimited: + "Cloudflare rate-limited the request (429). The documented budget is 300 GraphQL queries per five minutes.", + cfHttp: "Cloudflare returned HTTP {status}", + cfZoneToken: + "Cloudflare refused the account (not authorized for that account). The token most likely carries Zone Analytics Read; this dataset is account-scoped and needs Account → Account Analytics → Read.", + cfUnreachable: "Cloudflare could not be reached: {detail}", + cfNotJson: "Cloudflare returned a response that is not JSON", + cfTruncated: "Cloudflare truncated the per-path response; reduce the chunk size.", + cfTooManyGroups: + "Too many groups for one request: {paths} paths x {days} days exceeds Cloudflare's {max}-group page size. Reduce the chunk size.", + cfNoRetention: "Cloudflare returned no retention settings for this dataset", +} satisfies Record; + +export type MessageKey = keyof typeof en; + +const de: Record = { + noAnalyticsYet: "Noch keine Analysedaten", + visitsLastDays: "Besuche, letzte {days} Tage", + pageviewsLastDays: "Seitenaufrufe, letzte {days} Tage", + pageviews: "Seitenaufrufe", + visits: "Besuche", + colPage: "Seite", + colPath: "Pfad", + colViews: "Aufrufe", + colVisits: "Besuche", + colEntry: "Eintrag", + colCollection: "Kollektion", + colSource: "Quelle", + colCountry: "Land", + noPagesYet: "Noch keine Seiten erfasst.", + nothingRecorded: "Noch nichts erfasst.", + refresh: "Aktualisieren", + openAnalytics: "Analytics öffnen", + openInCloudflare: "In Cloudflare öffnen", + rangeDays: { one: "{count} Tag", other: "{count} Tage" }, + topEntries: "Meistbesuchte Einträge", + referrers: "Verweisquellen", + countries: "Länder", + entriesEmpty: + "Noch keine Zahlen pro Eintrag. Sie erscheinen, sobald die Synchronisierung Seiten den Einträgen zugeordnet hat.", + snapshotSince: "Verweisquellen und Länder seit {date}, aus der letzten Synchronisierung.", + historyStarts: "Verlauf beginnt am {date}", + coverageRange: "Zahlen pro Eintrag für die letzten {days} Tage.", + coveragePartial: "Zahlen pro Eintrag seit {date}; ältere Tage in diesem Zeitraum wurden nicht gelesen.", + coverageMatched: + "Zahlen pro Eintrag seit {date}. Tage, bevor das Plugin eine Seite einem Eintrag zugeordnet hat, zählen nicht pro Eintrag.", + coverageLive: "Zahlen pro Eintrag seit {date}, so weit zurück, wie der Anbieter jeden Seitenaufruf zählt.", + breakdownsSince: "Verweisquellen und Länder seit {date}, so weit zurück, wie der Anbieter jeden Seitenaufruf zählt.", + perEntry: "Pro Eintrag", + overview: "Übersicht", + + allCollections: "Alle", + modeEntries: "Jede Sprache einzeln", + modeCombined: "Sprachen zusammen", + firstPage: "Erste Seite", + rebuildIndex: "Index neu aufbauen", + rebuildRequested: + "Der Inhaltsindex wird neu aufgebaut. Das läuft mit den nächsten Synchronisierungen, jeweils ein paar Einträge.", + col7Days: "7 Tage", + col30Days: "30 Tage", + colLanguage: "Sprache", + colAllLanguages: "Alle Sprachen, 30 Tage", + colByLanguage: "Nach Sprache, {days} Tage", + colPublished: "Veröffentlicht", + sortMostViewed: "Meistbesuchte zuerst, letzte {days} Tage.", + sortLeastViewed: "Am wenigsten besuchte zuerst, letzte {days} Tage.", + showingRows: "Einträge {from} bis {to}.", + historyThirty: "Die 30-Tage-Zahlen zählen ab {date}, dem Beginn des gespeicherten Verlaufs.", + combinedPartial: + "Sprachen zusammengefasst über die ersten {count} Einträge in dieser Reihenfolge; spätere Einträge fehlen.", + indexingAt: "Inhalte werden indiziert: Kollektion {at} von {total}. Einträge erscheinen, sobald sie indiziert sind.", + rebuildingAt: "Der Inhaltsindex wird neu aufgebaut: Kollektion {at} von {total}.", + noEntriesYet: "Noch keine Einträge", + noEntriesIndexing: "Der Inhaltsindex wird noch aufgebaut. Einträge erscheinen nach und nach.", + noEntriesNoUrls: + "Kein veröffentlichter Eintrag hat eine öffentliche URL. Prüfe, ob die URL der Webseite in EmDash eingetragen ist und die Kollektionen URL-Muster haben.", + noEntriesHere: "Hier gibt es keine veröffentlichten Einträge.", + panelNoPage: "Noch keine Seite für diesen Eintrag", + panelNoPageDetail: + "Aufrufe erscheinen, sobald der Eintrag unter einer öffentlichen URL veröffentlicht ist und die nächste Synchronisierung gelaufen ist.", + panelNotPublished: "Derzeit nicht veröffentlicht; das sind die Zahlen aus der Zeit davor.", + panelCountedAt: "Gezählt unter {path}", + panelAllLanguages: "Alle Sprachen: {count} in 30 Tagen", + + checkSetup: "Einrichtung prüfen", + checkAgain: "Erneut prüfen", + backToAnalytics: "Zurück zur Analyse", + setupTitle: "Einrichtung", + setupAllGood: "Alles, wovon die Zahlen abhängen, ist eingerichtet.", + setupProblems: { + one: "{count} Problem verhindert oder verfälscht die Zahlen.", + other: "{count} Probleme verhindern oder verfälschen die Zahlen.", + }, + colCheck: "Prüfung", + colStatus: "Status", + colDetails: "Details", + statusOk: "OK", + statusProblem: "Problem", + statusWaiting: "Ausstehend", + statusSkipped: "Nicht geprüft", + checkSource: "Datenquelle", + checkCredentials: "API-Token und Konto", + checkAccess: "Zugriff auf Cloudflare", + checkSiteTag: "Site-Tag", + checkHosts: "Hostnamen", + checkSiteUrl: "Website-URL", + checkIndex: "Inhaltsindex", + checkScheduler: "Geplante Synchronisierung", + checkLastSync: "Letzte Synchronisierung", + sourceCloudflare: "Cloudflare Web Analytics.", + sourceDemo: "Demodaten: erzeugte Zahlen. Bei Cloudflare wird nichts geprüft.", + credentialsSaved: "Gespeichert.", + encryptionKeyHint: + "Wenn das Speichern des Tokens mit einem Verschlüsselungsfehler scheitert, fehlt der Website EMDASH_ENCRYPTION_KEY: Erzeuge einen Wert mit npx emdash secrets generate und hinterlege ihn als Secret (bei Cloudflare: wrangler secret put EMDASH_ENCRYPTION_KEY).", + needsCredentials: "Braucht API-Token und Konto-ID.", + accessOk: "Das Token kann die Web Analytics dieses Kontos lesen.", + needsAccess: "Braucht funktionierenden Zugriff auf Cloudflare.", + siteTagMissing: + "Kein Site-Tag eingetragen. Übernimm einen aus der Liste unten in das Feld Web Analytics site tag in den Einstellungen des Plugins.", + siteTagFound: "{tag}: {count} Seitenaufrufe in den letzten {days} Tagen.", + siteTagNotFound: + "Unter diesem Site-Tag gab es in den letzten {days} Tagen keinen Traffic. Die Sites mit Traffic stehen unten. Das Token im Beacon-Snippet ist ein anderer Wert als das Site-Tag.", + accountNoTraffic: + "Dieses Konto hat in den letzten {days} Tagen keinen Web-Analytics-Traffic gemeldet. Prüfe, ob der Beacon auf der Website eingebunden ist.", + needsSiteTag: "Braucht ein Site-Tag mit Traffic.", + hostsEvery: "Kein Hostnamen-Filter: Gezählt wird jeder Hostname, den dieses Site-Tag meldet ({hosts}).", + hostsCounted: "Gezählt: {hosts}.", + hostsPartly: "Gezählt: {counted}. Nicht gezählt: {excluded}.", + hostsNone: + "Cloudflare meldet dieses Site-Tag unter {reported}, das Plugin zählt aber nur {counted}. Ändere Hostnames to count in den Einstellungen des Plugins.", + siteUrlMissing: + "EmDash hat keine Website-URL gespeichert, deshalb haben Einträge keine öffentliche Adresse und Seitenaufrufe lassen sich ihnen nicht zuordnen. EmDash speichert sie (die Option emdash:site_url), wenn der Einrichtungsassistent auf der Live-Domain läuft.", + needsSiteUrl: "Braucht die Website-URL.", + indexMatched: { + one: "{count} Eintrag ist seiner Seite zugeordnet.", + other: "{count} Einträge sind ihren Seiten zugeordnet.", + }, + schedulerNotScheduled: "Die Synchronisierung ist noch nicht geplant. Das Öffnen des Dashboards plant sie ein.", + schedulerWaiting: + "Geplant alle {interval}, noch nicht gelaufen. Wenn sie nach {interval} noch nicht gelaufen ist, führt die Website keine geplanten Aufgaben aus.", + schedulerOk: "Zuletzt gelaufen {age}, geplant alle {interval}.", + schedulerStale: "Zuletzt gelaufen {age}, geplant ist sie aber alle {interval}: Der Scheduler der Website läuft nicht pünktlich.", + schedulerNeverRan: + "Eingeplant {age}, alle {interval}, und noch nie gelaufen: Der Scheduler der Website läuft nicht.", + schedulerRefreshStuck: "Eine {age} angeforderte Synchronisierung ist nicht gelaufen: Der Scheduler der Website läuft nicht.", + schedulerHowTo: + "Auf Cloudflare Workers führt EmDash geplante Aufgaben über einen Cron Trigger und den scheduled-Handler in src/worker.ts aus. npx emdash doctor prüft, ob beides eingerichtet ist.", + minutes: { one: "{count} Minute", other: "{count} Minuten" }, + hours: { one: "{count} Stunde", other: "{count} Stunden" }, + sitesTitle: "Sites in diesem Konto", + colSiteTag: "Site-Tag", + colHostnames: "Hostnamen", + sitesNote: + "Sites mit Seitenaufrufen in den letzten {days} Tagen. Im Cloudflare-Dashboard ist das Site-Tag auch die ID am Ende des Links Manage site der Site.", + + demoData: "Demodaten, keine echten Besuche", + synced: "Synchronisiert {age}", + notSynced: "Noch nicht synchronisiert", + estimatedCloudflare: "geschätzt (Cloudflare hat diesen Zeitraum per Stichprobe erfasst)", + estimatedSampled: "geschätzt (Stichprobe)", + todayCounting: "heute läuft die Zählung noch", + nothingMatched: "noch keine Seite einem Eintrag zugeordnet; „Einrichtung prüfen“ auf der Seite Analytics nennt den Grund", + lastAttemptFailed: "Letzter Versuch fehlgeschlagen: {error}", + lastAttemptFailedAge: "Letzter Versuch fehlgeschlagen {age}: {error}", + firstSyncPending: + "Die erste Synchronisierung ist noch nicht gelaufen. Sie ist eingeplant; die Zahlen erscheinen, sobald sie abgeschlossen ist.", + syncedNoViews: + "Synchronisiert {age}, aber Cloudflare meldet für dieses Site-Tag keine Seitenaufrufe. Prüfe, ob das Beacon auf der Webseite eingebunden ist.", + recently: "kürzlich", + + noEarlierPeriod: "noch kein früherer Zeitraum zum Vergleich", + noneEitherPeriod: "in keinem der beiden Zeiträume", + upFromNone: "gestiegen von null im vorigen Zeitraum", + vsPrevious: "{change} ggü. vorigem Zeitraum", + + syncRequested: "Synchronisierung angefordert. Die Zahlen werden beim nächsten geplanten Lauf aktualisiert.", + pageRefreshed: "Mit den neuesten Zahlen aktualisiert.", + pageRefreshFailed: "Der Anbieter hat nicht geantwortet; angezeigt werden die gespeicherten Zahlen.", + syncUnschedulable: + "Diese Webseite führt keine geplanten Aufgaben aus, daher lässt sich keine Synchronisierung anfordern.", + + notConfigured: "Analytics ist noch nicht eingerichtet: Trage {parts} in den Einstellungen des Plugins ein.", + partApiToken: "ein API-Token mit Account → Account Analytics → Read", + partAccountId: "die Cloudflare-Konto-ID", + noNetwork: "Analytics kann das Netzwerk nicht erreichen: Die Berechtigung network:request ist nicht erteilt.", + noSiteTagSites: "Kein Site-Tag gesetzt. Webseiten mit Besuchen in diesem Konto: {sites}.", + noSiteTagNoTraffic: + "Kein Site-Tag gesetzt, und dieses Konto hat in den letzten 30 Tagen keine Web-Analytics-Besuche gemeldet. Prüfe, ob das Beacon auf der Webseite eingebunden ist.", + indexingFailed: "Das Indizieren der Inhalte ist fehlgeschlagen: {detail}", + summingFailed: "Das Summieren der Aufrufe über 30 Tage ist fehlgeschlagen: {detail}", + storageUnavailable: "Die Speicher-Collections sind nicht verfügbar.", + cfNoAccount: + "Cloudflare hat kein Konto zurückgegeben. Prüfe die Konto-ID und dass das Token die Berechtigung Account → Account Analytics → Read hat.", + cfForbidden: "Cloudflare hat das Token abgelehnt (403). Es braucht Account → Account Analytics → Read.", + cfRateLimited: + "Cloudflare hat die Anfrage gedrosselt (429). Das dokumentierte Limit sind 300 GraphQL-Abfragen pro fünf Minuten.", + cfHttp: "Cloudflare hat HTTP {status} zurückgegeben", + cfZoneToken: + "Cloudflare hat das Konto abgelehnt (not authorized for that account). Das Token hat wahrscheinlich Zone Analytics Read; dieser Datensatz gilt fürs ganze Konto und braucht Account → Account Analytics → Read.", + cfUnreachable: "Cloudflare war nicht erreichbar: {detail}", + cfNotJson: "Cloudflare hat eine Antwort geliefert, die kein JSON ist", + cfTruncated: "Cloudflare hat die Antwort pro Pfad abgeschnitten; verringere die Anzahl der Pfade pro Durchlauf.", + cfTooManyGroups: + "Zu viele Gruppen für eine Anfrage: {paths} Pfade x {days} Tage übersteigen Cloudflares Seitengröße von {max} Gruppen. Verringere die Anzahl der Pfade pro Durchlauf.", + cfNoRetention: "Cloudflare hat für diesen Datensatz keine Aufbewahrungseinstellungen zurückgegeben", +}; + +const catalogues = { en, de } as const; + +export type Lang = keyof typeof catalogues; +export type Params = Record; + +/** A translatable failure, stored so the text follows the reader's language. */ +export interface Problem { + key: MessageKey; + params?: Params; +} + +/** The catalogue for an admin locale: `de`, `de-AT` and `de-CH` read German. */ +export function langOf(locale: string | undefined): Lang { + return locale?.toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; +} + +export function t(lang: Lang, key: MessageKey, params: Params = {}): string { + const message = catalogues[lang][key]; + const template = + typeof message === "string" + ? message + : new Intl.PluralRules(lang).select(Number(params.count ?? 0)) === "one" + ? message.one + : message.other; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** A problem in the reader's language. */ +export function problemText(lang: Lang, problem: Problem): string { + if (problem.key === "notConfigured") { + const missing = String(problem.params?.missing ?? "").split(",").filter(Boolean); + const parts = missing.map((key) => (key === "cfApiToken" ? t(lang, "partApiToken") : t(lang, "partAccountId"))); + return t(lang, "notConfigured", { parts: listOf(lang, parts) }); + } + return t(lang, problem.key, problem.params); +} + +function listOf(lang: Lang, parts: string[]): string { + try { + return new Intl.ListFormat(lang, { type: "conjunction" }).format(parts); + } catch { + return parts.join(", "); + } +} + +/** A failed `Result` whose error text is the English message for logs. */ +export function failure(key: MessageKey, params?: Params): { ok: false; error: string; problem: Problem } { + return { ok: false, error: t("en", key, params), problem: { key, ...(params && { params }) } }; +} diff --git a/packages/analytics/src/index/bootstrap.ts b/packages/analytics/src/index/bootstrap.ts new file mode 100644 index 0000000..fb7cf3a --- /dev/null +++ b/packages/analytics/src/index/bootstrap.ts @@ -0,0 +1,246 @@ +/** + * Catching up on content that existed before the plugin did. + * + * Forward maintenance through the content hooks only learns about entries + * that get saved. On any real site most entries were published long ago, + * so without a catch-up pass the join stays empty and the per-entry + * numbers — the whole point of this plugin — never appear until somebody + * edits something. + * + * Two things keep this cheap: + * + * - **It rides on the sync tick** rather than on `plugin:activate`, which + * is dispatched only by the admin Enable endpoint and never fires for a + * site that registers the plugin through `plugins: []`. + * - **It walks a few entries per tick.** `getPublicUrl()` is one bridge + * call per entry and a sandboxed invocation gets ten in total, so the + * page size is deliberately tiny and the pass spreads over many ticks. + * + * The caller keeps the cursor in its sync state and saves it only after + * this page's rows are written, so a tick that dies repeats its page + * instead of skipping it. Repeating is harmless: a row is written only + * when it is new or its entry changed. + * + * The same walk repairs the index. A row whose entry has changed in a way + * the content hooks did not report (or that an older build stored wrongly, + * such as a translation filed under its own group) is rewritten with the + * entry's current fields and its view counts kept. + * + * Unlike `indexEntry`, this reads the title out of the listing it already + * has instead of asking for the schema and the entry again. Four calls per + * entry would be wrong here; one is right. + */ + +import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { entriesStore } from "../store/access.js"; +import type { EntryRow } from "../store/rows.js"; +import { normalizePath } from "./paths.js"; + +/** + * Bumped when a walk starts storing something earlier walks did not, or + * stored wrongly. A site whose last completed walk has a lower version + * gets walked again (see `indexOutdated`). + * + * 2: the real translation group, `publishedAt`, the collection's title field. + */ +export const INDEX_VERSION = 2; + +/** + * Entries visited per tick. + * + * A tick spends seven bridge calls around them — state read, settings + * read, collection list, content list, the existence check, the write, + * the state write — and one `getPublicUrl()` per entry. Three is what + * fits in ten. + */ +export const INDEX_PAGE_SIZE = 3; + +/** Where the walk stands: a collection, and the listing cursor inside it. */ +export interface IndexCursor { + collection: string; + cursor?: string; +} + +export interface BootstrapResult { + /** Where the next tick continues, or undefined once the pass is complete. */ + next: IndexCursor | undefined; + /** Rows added. */ + indexed: number; + /** Existing rows rewritten because their entry changed. */ + repaired: number; + /** Display labels of the routable collections, by slug; empty when none were listed. */ + labels: Record; + skipped: number; + complete: boolean; +} + +/** + * Index one small page of pre-existing content, starting at `from`. + * + * Collections are walked in slug order, so a collection that disappears + * mid-pass is stepped over rather than wedging the walk. + */ +export async function bootstrapIndex( + ctx: PluginContext, + from: IndexCursor | undefined, + now: Date, +): Promise { + const done: BootstrapResult = { next: undefined, indexed: 0, repaired: 0, labels: {}, skipped: 0, complete: true }; + const entries = entriesStore(ctx); + if (!entries || !ctx.content?.getPublicUrl || !ctx.schema) return done; + + // Non-routable and hidden collections are skipped rather than guessed + // at: an entry with no URL pattern has no page to attribute traffic to. + const routable = (await ctx.schema.listCollections()).filter((c) => c.routable && !c.hidden); + const collections = routable.map((c) => c.slug).sort(); + const labels = Object.fromEntries(routable.map((c) => [c.slug, c.label || c.slug])); + + const at = from ? collections.findIndex((slug) => slug >= from.collection) : 0; + const collection = collections[at]; + if (collection === undefined) return { ...done, labels }; + const titleField = routable.find((c) => c.slug === collection)?.titleField ?? null; + + const cursor = collection === from?.collection ? from.cursor : undefined; + // Drafts have no public URL, so listing them would spend a + // `getPublicUrl()` each to learn nothing. + const page = await ctx.content.list(collection, { + limit: INDEX_PAGE_SIZE, + where: { status: "published" }, + ...(cursor ? { cursor } : {}), + }); + + const rows: Array<{ id: string; data: EntryRow }> = []; + let skipped = 0; + + for (const item of page.items) { + const id = idOf(item); + if (!id) { + skipped++; + continue; + } + const url = await ctx.content.getPublicUrl(collection, id); + if (!url) { + // Non-routable, no slug, or no site URL configured. A normal + // answer, not a failure. + skipped++; + continue; + } + const path = normalizePath(url, ctx.site.trailingSlash); + const publishedAt = publishedAtOf(item); + rows.push({ + id: path, + data: { + path, + collection, + entryId: id, + translationGroup: translationGroupOf(item) ?? id, + locale: localeOf(item) ?? ctx.site.locale, + title: titleOf(item, titleField), + status: "published", + ...(publishedAt && { publishedAt }), + views7: 0, + views30: 0, + updatedAt: now.toISOString(), + }, + }); + } + + const { writes, added } = rows.length > 0 ? await changedRows(entries, rows) : { writes: [], added: 0 }; + if (writes.length > 0) await entries.putMany(writes); + + const following = collections[at + 1]; + const next = page.cursor + ? { collection, cursor: page.cursor } + : following !== undefined + ? { collection: following } + : undefined; + + return { + next, + indexed: added, + repaired: writes.length - added, + labels, + skipped: skipped + (rows.length - writes.length), + complete: next === undefined, + }; +} + +/** + * The rows that need writing: new ones, and existing ones whose entry + * changed. An existing row keeps its view counts, which describe traffic + * the walk knows nothing about, and keeps its title: the content hooks + * rewrite it on every save, so the walk only fills one that is missing. + */ +async function changedRows( + entries: NonNullable>, + rows: Array<{ id: string; data: EntryRow }>, +): Promise<{ writes: Array<{ id: string; data: EntryRow }>; added: number }> { + const existing = await entries.getMany(rows.map((r) => r.id)); + const writes: Array<{ id: string; data: EntryRow }> = []; + let added = 0; + for (const row of rows) { + const stored = existing.get(row.id); + if (!stored) { + writes.push(row); + added++; + continue; + } + const merged: EntryRow = { + ...stored, + collection: row.data.collection, + entryId: row.data.entryId, + translationGroup: row.data.translationGroup, + locale: row.data.locale, + title: stored.title || row.data.title, + status: row.data.status, + ...((row.data.publishedAt ?? stored.publishedAt) && { publishedAt: row.data.publishedAt ?? stored.publishedAt }), + }; + if (DESCRIPTIVE.some((key) => merged[key] !== stored[key])) { + writes.push({ id: row.id, data: { ...merged, updatedAt: row.data.updatedAt } }); + } + } + return { writes, added }; +} + +/** The fields a walk may correct. Everything else on a row is the sync's. */ +const DESCRIPTIVE = ["collection", "entryId", "translationGroup", "locale", "title", "status", "publishedAt"] as const; + +function idOf(item: PluginContentItem): string | null { + const record = item as unknown as Record; + return typeof record.id === "string" ? record.id : null; +} + +/** + * The entry's display title: the collection's declared title field first, + * then the usual names. + */ +function titleOf(item: PluginContentItem, titleField: string | null): string { + const record = item as unknown as Record; + const data = typeof record.data === "object" && record.data !== null ? (record.data as Record) : null; + const keys = [titleField, "title", "name", "heading"].filter((k): k is string => Boolean(k)); + for (const key of keys) { + for (const source of [record, data]) { + const value = source?.[key]; + if (typeof value === "string" && value) return value; + } + } + return ""; +} + +function publishedAtOf(item: PluginContentItem): string | undefined { + const record = item as unknown as Record; + const value = record.publishedAt ?? record.published_at; + return typeof value === "string" && value ? value : undefined; +} + +function localeOf(item: PluginContentItem): string | null { + const record = item as unknown as Record; + return typeof record.locale === "string" ? record.locale : null; +} + +function translationGroupOf(item: PluginContentItem): string | null { + const record = item as unknown as Record; + return typeof record.translationGroup === "string" && record.translationGroup ? record.translationGroup : null; +} diff --git a/packages/analytics/src/index/entries.ts b/packages/analytics/src/index/entries.ts new file mode 100644 index 0000000..c2dea17 --- /dev/null +++ b/packages/analytics/src/index/entries.ts @@ -0,0 +1,203 @@ +/** + * The path -> entry index: the join that makes this plugin different from + * every other analytics plugin in the catalogue. + * + * **Built forward, never parsed backwards.** Turning a provider's + * `requestPath` back into an entry would mean reimplementing core's URL + * patterns, and core's own `resolveEmDashPath()` is not on `ctx`, is + * anchored so `/blog/foo/` fails, and does not strip locale prefixes. + * Instead every save and publish asks `getPublicUrl()` where the entry + * actually lives and stores that. The result is exact by construction, + * costs nothing at lookup time, and rolls translations up for free + * because `getTranslations()` hands back the group. + * + * Rows for unpublished and deleted entries are kept, marked rather than + * removed, so history stays attributable; `reconcile` prunes them once + * their traffic has aged out of retention. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { entriesStore, BIND_LIMIT } from "../store/access.js"; +import type { EntryRow } from "../store/rows.js"; +import { normalizePath } from "./paths.js"; + +export interface EntryRef { + collection: string; + id: string; +} + +/** + * Pull `{ collection, id }` out of whichever content event fired. + * + * The five content hooks do not share one event shape: + * `content:afterDelete` carries `{ id, collection }` while the others + * carry `{ content, collection }` with the id inside `content`. Reading + * both shapes here keeps that difference in one place instead of in five + * handlers. + */ +export function entryRefOf(event: unknown): EntryRef | null { + if (typeof event !== "object" || event === null) return null; + const e = event as Record; + const collection = typeof e.collection === "string" ? e.collection : null; + if (!collection) return null; + + const direct = typeof e.id === "string" ? e.id : null; + const content = typeof e.content === "object" && e.content !== null ? (e.content as Record) : null; + const nested = content && typeof content.id === "string" ? content.id : null; + + const id = direct ?? nested; + return id ? { collection, id } : null; +} + +export type EntryStatus = EntryRow["status"]; + +/** + * Record where an entry is published, or mark that it no longer is. + * + * Returns the path it indexed, or `null` when the entry has no public URL + * — unpublished, non-routable, no slug, no configured site URL. That is a + * normal answer, not an error: plenty of entries are legitimately not on + * the web, and guessing a URL for them would poison the join. + */ +export async function indexEntry( + ctx: PluginContext, + ref: EntryRef, + status: EntryStatus, + now: Date, + /** + * The entry's fields as the hook already delivered them. Passing them in + * saves a `ctx.content.get()` round trip per save — the hook is holding + * the record, so asking the host for it again costs a bridge call from a + * ten-call budget to learn something already known. + */ + fields?: Record, +): Promise { + if (!ctx.content?.getPublicUrl) { + ctx.log.warn("analytics: content:read is declared but getPublicUrl is unavailable"); + return null; + } + + // Called through the context rather than extracted and re-bound: the + // sandbox exposes these as proxied methods, and `fn.call(ctx.content, …)` + // does not survive that indirection. + const url = await ctx.content.getPublicUrl(ref.collection, ref.id); + if (!url) return null; + + const path = normalizePath(url, ctx.site.trailingSlash); + + const [translationGroup, locale] = await readTranslation(ctx, ref); + const title = await readTitle(ctx, ref, fields); + + // The entry's rows by id rather than the one row by path: after a slug + // change the row at the old path is still marked published, and the + // per-entry page would list the entry twice. + const entries = entriesStore(ctx); + const mine = entries ? (await entries.query({ where: { entryId: ref.id }, limit: BIND_LIMIT })).items : []; + + // Preserve the accumulated numbers: this hook knows about the entry, + // not about its traffic, and overwriting the view counts with zeroes + // here would blank the content page on every save. They belong to the + // path, so a path another entry used before keeps its numbers too. + const existing = mine.find((item) => item.id === path)?.data ?? (await entries?.get(path)); + const publishedAt = publishedAtOf(fields) ?? existing?.publishedAt; + + const row: EntryRow = { + path, + collection: ref.collection, + entryId: ref.id, + translationGroup: translationGroup ?? ref.id, + locale: locale ?? ctx.site.locale, + title, + status, + ...(publishedAt && { publishedAt }), + views7: existing?.views7 ?? 0, + views30: existing?.views30 ?? 0, + ...(existing?.recentViews !== undefined && { recentViews: existing.recentViews }), + ...(existing?.olderViews !== undefined && { olderViews: existing.olderViews }), + ...(existing?.olderDay !== undefined && { olderDay: existing.olderDay }), + updatedAt: now.toISOString(), + }; + + const moved = mine + .filter((item) => item.id !== path && item.data.status === "published") + .map((item) => ({ id: item.id, data: { ...item.data, status: "moved" as const, updatedAt: now.toISOString() } })); + await entries?.putMany([{ id: path, data: row }, ...moved]); + return path; +} + +/** + * Mark an entry's row without needing its public URL. + * + * On unpublish and delete `getPublicUrl()` returns `null` — the entry is + * no longer routable — so the path has to be found by the entry id + * instead. `entryId` is a declared index, which is what makes that query + * legal. + */ +export async function markEntryStatus( + ctx: PluginContext, + ref: EntryRef, + status: EntryStatus, + now: Date, +): Promise { + const collection = entriesStore(ctx); + if (!collection) return 0; + + const page = await collection.query({ where: { entryId: ref.id }, limit: 100 }); + if (page.items.length === 0) return 0; + + const updates = page.items.map(({ id, data }) => ({ + id, + data: { ...data, status, updatedAt: now.toISOString() }, + })); + await collection.putMany(updates); + return updates.length; +} + +async function readTranslation(ctx: PluginContext, ref: EntryRef): Promise<[string | null, string | null]> { + if (!ctx.content?.getTranslations) return [null, null]; + try { + const result = await ctx.content.getTranslations(ref.collection, ref.id); + const mine = result.translations.find((t) => t.id === ref.id); + return [result.translationGroup, mine?.locale ?? null]; + } catch { + // A missing translation group is not worth failing a save over. + return [null, null]; + } +} + +/** + * The entry's display title. + * + * Read through the collection's declared `titleField` rather than by + * assuming the field is called "title" — but only out of the record the + * hook already carried. If the collection declares a title field that is + * not present, or nothing was carried, the widget falls back to showing + * the path, which is a worse label but never a wrong one. + */ +async function readTitle(ctx: PluginContext, ref: EntryRef, fields?: Record): Promise { + if (!fields) return ""; + + let titleField: string | null = null; + try { + titleField = (await ctx.schema?.getCollection(ref.collection))?.titleField ?? null; + } catch { + // Schema lookup is a convenience here, not a requirement. + } + + const nested = typeof fields.data === "object" && fields.data !== null ? (fields.data as Record) : null; + const candidates = [titleField, "title", "name", "heading"].filter((k): k is string => Boolean(k)); + + for (const key of candidates) { + for (const source of [fields, nested]) { + const value = source?.[key]; + if (typeof value === "string" && value) return value; + } + } + return ""; +} + +function publishedAtOf(fields: Record | undefined): string | undefined { + const value = fields?.publishedAt ?? fields?.published_at; + return typeof value === "string" && value ? value : undefined; +} diff --git a/packages/analytics/src/index/paths.ts b/packages/analytics/src/index/paths.ts new file mode 100644 index 0000000..881a8d8 --- /dev/null +++ b/packages/analytics/src/index/paths.ts @@ -0,0 +1,136 @@ +/** + * Path normalization. Pure. + * + * Two sides have to agree on what a path is: what Cloudflare reports as + * `requestPath`, and what `ctx.content.getPublicUrl()` says the entry + * lives at. When they disagree the join silently produces nothing, which + * is the worst possible failure — the dashboard looks fine and every + * entry reads zero. + * + * This is not hypothetical. The first live pull from a production site on + * 2026-09-20 returned `/datenschutz` and `/datenschutz/` as two separate + * rows, each with its own view count, for one page. + * + * What this deliberately does NOT do: + * + * - **Lower-case.** Paths are case-sensitive by specification, and folding + * them would merge URLs that a site may legitimately serve differently. + * Hosts differ; paths do not. + * - **Strip locale prefixes.** The index is built forward from + * `getPublicUrl()`, so an entry's stored path already carries whatever + * prefix the site serves, and the provider reports the same prefix. + * Both sides match without help. Rolling translations together is the + * job of `translationGroup`, not of guessing which leading segment is a + * language code — `/de/` is a locale on one site and a page on another. + */ + +/** Astro's routing policy, as `SiteInfo.trailingSlash` reports it. */ +export type TrailingSlash = "always" | "never" | "ignore"; + +/** + * Canonical form for a path under a given trailing-slash policy. + * + * `ignore` is the interesting case: Astro serves both spellings, so the + * provider will report both, and the two have to be folded into one row + * or a page's views arrive split in half. We fold onto the trailing-slash + * spelling because that is what `getPublicUrl()` emits by default. + */ +export function normalizePath(raw: string, trailingSlash: TrailingSlash = "ignore"): string { + let path = stripOrigin(raw.trim()); + + // Query and hash are not part of the page's identity. Cloudflare + // reports them separately anyway, but a hand-entered filter may not. + const cut = Math.min(indexOrEnd(path, "?"), indexOrEnd(path, "#")); + path = path.slice(0, cut); + + path = decodeOnce(path); + + if (!path.startsWith("/")) path = `/${path}`; + path = path.replace(/\/{2,}/g, "/"); + + // The root is "/" under every policy; there is no slash-less spelling. + if (path === "/") return "/"; + + const bare = path.replace(/\/+$/, "") || "/"; + if (bare === "/") return "/"; + + return trailingSlash === "never" ? bare : `${bare}/`; +} + +/** + * Canonical spelling of a hostname: case-insensitive by specification, and + * a trailing dot is the same host. + * + * Deliberately does **not** fold `www.` away. `example.com` and + * `www.example.com` are two distinct `requestHost` values in Cloudflare's + * data, and both have to survive into `requestHost_in` or the filter drops + * half the site's traffic. Folding them here once collapsed the host list + * to a duplicate pair; `siteHosts` is the place that knows both spellings + * belong to one site. + */ +export function normalizeHost(raw: string): string { + return raw.trim().toLowerCase().replace(/\.$/, ""); +} + +/** + * The hosts a site's beacon may report under, derived from `site.url`. + * + * A single Cloudflare site tag routinely covers more than one hostname — + * one production site's record matched its apex, its `www` host and a + * `*.pages.dev` preview domain. Filtering on the apex alone would drop the + * `www` half of the traffic, so both spellings go into `requestHost_in`. + */ +export function siteHosts(siteUrl: string): string[] { + let host: string; + try { + host = new URL(siteUrl).hostname.toLowerCase().replace(/\.$/, ""); + } catch { + return []; + } + if (!host) return []; + const apex = host.replace(/^www\./, ""); + return apex === host ? [apex, `www.${apex}`] : [apex, host]; +} + +/** + * Paths the plugin must never count. + * + * The admin is an injected Astro page with its own ``, so a beacon + * injected at `head-inline` runs there too and turns editor sessions into + * page views, and Cloudflare's automatic setup injects into every HTML + * response regardless. Provider queries exclude these paths where the + * provider can filter; this catches what a filter pattern cannot express. + */ +export function isInternalPath(path: string): boolean { + return path === "/_emdash" || path.startsWith("/_emdash/"); +} + +function stripOrigin(value: string): string { + if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value; + try { + const url = new URL(value); + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return value; + } +} + +function indexOrEnd(value: string, needle: string): number { + const i = value.indexOf(needle); + return i === -1 ? value.length : i; +} + +/** + * Percent-decode once, tolerantly. + * + * `decodeURIComponent` throws on a malformed sequence such as `%zz`, and + * a crash inside a sync tick would cost the whole batch. A path we cannot + * decode is still a usable key, so it is kept verbatim. + */ +function decodeOnce(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/packages/analytics/src/plugin.ts b/packages/analytics/src/plugin.ts new file mode 100644 index 0000000..723898d --- /dev/null +++ b/packages/analytics/src/plugin.ts @@ -0,0 +1,362 @@ +/** + * Hooks, the cron dispatcher and the single admin route. + * + * Hooks and routes are declared here rather than in `emdash-plugin.jsonc` + * because the authored manifest is strict and rejects both keys outright; + * the build probes this module and writes them into the wire manifest. + * + * The route's `permission` is the load-bearing line. An implicit `admin` + * route resolves with no permission, so dispatch falls back to + * `plugins:manage`, which is admin-only — a plugin that forgets this ships + * a dashboard widget no editor can see. `plugins:read` is EDITOR and above + * (`packages/auth/src/rbac.ts:83`), taken from the table rather than + * invented. Authors and contributors still see the widget card and get a + * 403 from the host before this code runs; that is an upstream gap, and + * the README says which roles see numbers. + */ + +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; + +import { langOf, problemText, t, type Lang } from "./i18n.js"; +import { entryRefOf, indexEntry, markEntryStatus } from "./index/entries.js"; +import { readSettings, type SettingsResult } from "./settings.js"; +import { + buildProvider, + ensureScheduled, + noteWaiting, + readState, + rememberCollectionLabels, + requestRebuild, + requestRefresh, + runReconcile, + runSync, + RECONCILE_TASK, + REFRESH_TASK, + SYNC_TASK, +} from "./sync/scheduler.js"; +import { + DEFAULT_RANGE, + loadPage, + PAGE_PATH, + PAGE_REFRESH_ACTION, + parseRange, + RANGE_ACTION, + renderPage, + SETUP_ACTION, + type RangeDays, +} from "./ui/page.js"; +import { loadSetup, renderSetup } from "./ui/setup.js"; +import { CONTENT_PATH, loadContent, parseContentInput, renderContent } from "./ui/content.js"; +import { loadPanel, renderPanel } from "./ui/panel.js"; +import { loadWidget, renderWidget, REFRESH_ACTION } from "./ui/widget.js"; +import { DEFAULT_SYNC_INTERVAL } from "./settings.js"; + +/** + * The cron hook's own timeout. The default is 5 000 ms, which a tick that + * makes an outbound HTTPS request and several storage round-trips can + * exceed on a cold isolate. + */ +const CRON_TIMEOUT_MS = 30_000; + +const plugin: SandboxedPlugin = { + hooks: { + /** + * Fires only when an administrator clicks Enable. A site that + * registers the plugin through `plugins: []` never gets one, which is + * why this is not the only place scheduling happens. + */ + "plugin:activate": async (_event, ctx) => { + await scheduleFromSettings(ctx); + }, + + cron: { + timeout: CRON_TIMEOUT_MS, + handler: async (event, ctx) => { + if (event.name === SYNC_TASK) { + await runSync(ctx); + return; + } + + if (event.name === REFRESH_TASK) { + await runSync(ctx, new Date(), { overview: true }); + return; + } + + if (event.name === RECONCILE_TASK) { + const result = await readSettings(ctx); + const retentionDays = result.ok ? result.settings.retentionDays : 90; + await runReconcile(ctx, retentionDays); + return; + } + + ctx.log.debug("analytics: ignoring unknown cron task", { name: event.name }); + }, + }, + + // Content hooks never block a save: a failure to index is a reporting + // problem, not an editorial one. + "content:afterSave": { + errorPolicy: "continue", + handler: async (event, ctx) => { + // Also the reliable scheduling entry point for `plugins: []` + // installs, where `plugin:activate` never fires. + await scheduleFromSettings(ctx); + await index(ctx, event, "published"); + }, + }, + "content:afterPublish": { + errorPolicy: "continue", + handler: async (event, ctx) => { + await index(ctx, event, "published"); + }, + }, + "content:afterRestore": { + errorPolicy: "continue", + handler: async (event, ctx) => { + await index(ctx, event, "published"); + }, + }, + // Unpublish and delete keep the row and mark it, so past traffic stays + // attributable to the entry that earned it. `getPublicUrl()` returns + // null for these, so the row is found by `entryId` instead. + "content:afterUnpublish": { + errorPolicy: "continue", + handler: async (event, ctx) => { + const ref = entryRefOf(event); + if (!ref) return; + try { + await markEntryStatus(ctx, ref, "unpublished", new Date()); + } catch (error) { + ctx.log.warn("analytics: could not mark entry unpublished", { error: String(error) }); + } + }, + }, + "content:afterDelete": { + errorPolicy: "continue", + handler: async (event, ctx) => { + const ref = entryRefOf(event); + if (!ref) return; + try { + await markEntryStatus(ctx, ref, "deleted", new Date()); + } catch (error) { + ctx.log.warn("analytics: could not mark entry deleted", { error: String(error) }); + } + }, + }, + }, + + routes: { + admin: { + permission: "plugins:read", + handler: async (routeCtx, ctx) => { + const interaction = parseInteraction(routeCtx.input); + const now = new Date(); + // English when the host passes no locale, which is the case for + // plugins registered in `plugins: []`. + const lang = langOf(routeCtx.ui?.locale); + + if (interaction.surface === "page") { + return await handlePage(ctx, interaction, now, lang); + } + if (interaction.surface === "content") { + return await handleContent(ctx, interaction.input, now, lang); + } + + if (interaction.kind === "refresh") { + const toast = await refresh(ctx, await readSettings(ctx), now, lang); + const state = await readState(ctx); + const input = await loadWidget(ctx, state, now, lang); + return { blocks: renderWidget(input), toast }; + } + + // The widget's own page load is the third scheduling entry + // point, and the one the PoC measures: after a single dashboard + // visit, `_emdash_cron_tasks` should hold the sync row without + // anybody having clicked Enable. + await scheduleFromSettings(ctx); + + const state = await readState(ctx); + await noteWaiting(ctx, state, now); + const input = await loadWidget(ctx, state, now, lang); + return { blocks: renderWidget(input) }; + }, + }, + + // The entry editor's Views panel. Its own route because it needs a + // lower permission than the analytics pages: `content:edit_own` is + // AUTHOR and above, and the host additionally checks that the user + // may edit this entry (`http-route-dispatch.ts`), so authors see + // their own entries' numbers and nobody else's. + panel: { + permission: "content:edit_own", + handler: async (routeCtx, ctx) => { + const ui = routeCtx.ui; + const entryId = ui?.surface === "content-editor-panel" ? ui.entry.id : null; + const state = await readState(ctx); + return { blocks: renderPanel(await loadPanel(ctx, entryId, state, new Date(), ui?.locale)) }; + }, + }, + }, +}; + +/** + * Index best-effort. + * + * `errorPolicy: "continue"` stops the host aborting the save, but the + * handler still has to not throw: failing to record where an entry lives is + * a reporting problem, and an editor pressing Publish should never see it. + */ +async function index(ctx: PluginContext, event: unknown, status: "published"): Promise { + const ref = entryRefOf(event); + if (!ref) return; + try { + await indexEntry(ctx, ref, status, new Date(), contentOf(event)); + } catch (error) { + ctx.log.warn("analytics: could not index entry", { collection: ref.collection, error: String(error) }); + } +} + +/** The entry's fields as the hook delivered them, when it delivered any. */ +function contentOf(event: unknown): Record | undefined { + if (typeof event !== "object" || event === null) return undefined; + const content = (event as Record).content; + return typeof content === "object" && content !== null ? (content as Record) : undefined; +} + +/** + * Schedule with whatever interval is configured, falling back to the + * default when settings are not readable yet. + * + * `CronAccess.schedule` upserts on (plugin, task), so calling this from + * three different entry points costs one statement and cannot duplicate a + * task. + */ +async function scheduleFromSettings(ctx: PluginContext): Promise { + let interval = DEFAULT_SYNC_INTERVAL; + try { + const result = await readSettings(ctx); + if (result.ok) interval = result.settings.syncInterval; + else if (result.partial.syncInterval) interval = result.partial.syncInterval; + } catch { + // Fall through with the default: an unscheduled plugin is worse than + // one scheduled at the wrong cadence. + } + await ensureScheduled(ctx, interval); +} + +type Interaction = + | { surface: "widget"; kind: "load" | "refresh" } + | { surface: "page"; kind: "load" | "refresh" | "setup"; range: RangeDays } + | { surface: "content"; input: Record }; + +/** + * Block Kit sends `page_load` on mount and `block_action` on an element, + * both naming the page or widget they came from. Anything unrecognised + * renders rather than errors: an unknown interaction is not worth a broken + * card. + */ +function parseInteraction(input: unknown): Interaction { + if (typeof input !== "object" || input === null) return { surface: "widget", kind: "load" }; + const record = input as Record; + const isAction = record.type === "block_action"; + + if (record.page === CONTENT_PATH) return { surface: "content", input: record }; + + if (record.page === PAGE_PATH) { + if (isAction && record.action_id === PAGE_REFRESH_ACTION) { + return { surface: "page", kind: "refresh", range: parseRange(record.value) }; + } + if (isAction && record.action_id === RANGE_ACTION) { + return { surface: "page", kind: "load", range: parseRange(record.value) }; + } + if (isAction && record.action_id === SETUP_ACTION) { + return { surface: "page", kind: "setup", range: parseRange(record.value) }; + } + return { surface: "page", kind: "load", range: DEFAULT_RANGE }; + } + + if (isAction && record.action_id === REFRESH_ACTION) return { surface: "widget", kind: "refresh" }; + return { surface: "widget", kind: "load" }; +} + +/** + * The analytics page. It reads the provider live, so its Refresh just + * loads it again and says whether the live read worked; it schedules + * nothing. Loading it does not schedule the cron either: the dashboard + * widget does that on every visit. + */ +async function handlePage( + ctx: PluginContext, + interaction: Extract, + now: Date, + lang: Lang, +) { + if (interaction.kind === "setup") { + return { blocks: renderSetup(await loadSetup(ctx, now), interaction.range, lang) }; + } + + const settings = await readSettings(ctx); + const state = await readState(ctx); + const provider = liveProvider(ctx, settings); + const input = await loadPage(ctx, state, interaction.range, now, dashboardUrl(ctx, settings), lang, provider); + const blocks = renderPage(input); + if (interaction.kind !== "refresh") return { blocks }; + + const live = Boolean(input.breakdowns); + return { + blocks, + toast: + live || !provider + ? { message: t(lang, "pageRefreshed"), type: "success" as const } + : { message: t(lang, "pageRefreshFailed"), type: "error" as const }, + }; +} + +/** + * The per-entry page. It reads storage only: the numbers it sorts by are + * the ones the sync stored, so asking the provider live would show values + * the order does not follow. + */ +async function handleContent(ctx: PluginContext, input: Record, now: Date, lang: Lang) { + const { view, rebuild } = parseContentInput(input); + const state = rebuild ? await requestRebuild(ctx) : await readState(ctx); + const content = await loadContent(ctx, view, state, now, lang); + await rememberCollectionLabels(ctx, state, Object.fromEntries(content.collections.map((c) => [c.slug, c.label]))); + const blocks = renderContent(content); + if (!rebuild) return { blocks }; + return { blocks, toast: { message: t(lang, "rebuildRequested"), type: "success" as const } }; +} + +/** The provider to ask live, or null when it could not answer anyway. */ +function liveProvider(ctx: PluginContext, settings: SettingsResult) { + if (!settings.ok) return null; + if (settings.settings.provider !== "demo" && !settings.settings.siteTag) return null; + return buildProvider(ctx, settings.settings); +} + +/** The configured provider's own dashboard, or null (demo data, or not configured). */ +function dashboardUrl(ctx: PluginContext, settings: SettingsResult): string | null { + if (!settings.ok) return null; + return buildProvider(ctx, settings.settings)?.dashboardUrl?.() ?? null; +} + +/** + * Refresh schedules a tick rather than running one (see `requestRefresh`). + * An incomplete configuration is answered at once: the tick could only + * record the same problem, minutes later. + */ +async function refresh( + ctx: PluginContext, + settings: SettingsResult, + now: Date, + lang: Lang, +): Promise<{ message: string; type: "success" | "error" }> { + if (!settings.ok) { + const problem = { key: "notConfigured" as const, params: { missing: settings.missing.join(",") } }; + return { message: problemText(lang, problem), type: "error" }; + } + if (!(await requestRefresh(ctx, now))) return { message: t(lang, "syncUnschedulable"), type: "error" }; + return { message: t(lang, "syncRequested"), type: "success" }; +} + +export default plugin; diff --git a/packages/analytics/src/providers/cloudflare.ts b/packages/analytics/src/providers/cloudflare.ts new file mode 100644 index 0000000..b288da9 --- /dev/null +++ b/packages/analytics/src/providers/cloudflare.ts @@ -0,0 +1,578 @@ +/** + * The Cloudflare Web Analytics adapter — the one place that talks to + * Cloudflare. + * + * The parser is tolerant on purpose: a schema change must read as "no data + * plus an error", never as a thrown exception inside a dashboard widget. + * Four things every query here gets right, because each one fails + * silently when it is wrong: + * + * - **`siteTag` is not the beacon token.** The token in the beacon snippet + * and the site tag the reporting API filters on are different values + * (checked against the live API on 2026-09-20). A query with the token + * returns nothing at all. + * - **`bot: 0`**, or bot traffic counts as page views. + * - **A host filter.** One site tag routinely covers several hostnames — + * apex, `www`, and `*.pages.dev` preview deploys — so without one, + * preview traffic lands in production numbers. + * - **The sampling signal.** `avg { sampleInterval }` is selected + * everywhere, because `count` is already scaled by it and a caller that + * does not know the factor cannot tell an exact number from an estimate. + * + * Field names are not guesses. Every identifier below was read out of the + * live schema by introspection on 2026-09-20; note that Cloudflare's + * GraphQL types are lower-case (`viewer`, `account`), so introspecting + * `"Account"` returns null and sends you looking for a problem that is not + * there. + */ + +import { failure, type Problem } from "../i18n.js"; +import { isInternalPath, normalizeHost, normalizePath, type TrailingSlash } from "../index/paths.js"; +import { daysBetween, enumerateDays, UNSAMPLED_WINDOW_DAYS, type Day } from "../sync/window.js"; +import type { + DailyRow, + DateRange, + FetchLike, + LabelledRow, + Overview, + PathDayRow, + PathRow, + Provider, + ProviderCapabilities, + Result, + Retention, + Site, +} from "./types.js"; + +export const GRAPHQL_ENDPOINT = "https://api.cloudflare.com/client/v4/graphql"; + +/** Verified present on the live schema, 2026-09-20. */ +export const RUM_DATASET = "rumPageloadEventsAdaptiveGroups"; + +/** `maxPageSize` from the live settings probe. */ +const MAX_PAGE_SIZE = 10_000; + +const TOP_PATHS_LIMIT = 20; +const REFERRERS_LIMIT = 20; +const COUNTRIES_LIMIT = 50; + +/** The admin's pages as a `LIKE` pattern; see `isInternalPath`. */ +const INTERNAL_PATHS = "/_emdash/%"; + +export const CLOUDFLARE_CAPABILITIES: ProviderCapabilities = { + batchPaths: "filter", + // Not an API cap: 10 000 paths were accepted in one request. This is a + // self-imposed figure that keeps paths x days clear of `maxPageSize` + // and the per-tick write budget within reach. + maxPathsPerQuery: 100, + meteredReads: false, + defaultInterval: "*/15 * * * *", + // No uniques. Cloudflare Web Analytics does not measure them, and + // inventing one from visits would be a lie with a number attached. + metrics: ["pageviews", "visits"], + breakdowns: ["referrers", "countries"], + exactWindowDays: UNSAMPLED_WINDOW_DAYS, + reportsSampling: true, +}; + +export interface CloudflareConfig { + apiToken: string; + accountId: string; + siteTag: string; + /** Hosts to filter on. Empty means "do not filter", which counts previews. */ + hosts?: string[]; + trailingSlash?: TrailingSlash; + fetch: FetchLike; +} + +export function createCloudflareProvider(config: CloudflareConfig): Provider { + return new CloudflareProvider(config); +} + +/** + * The site's Web Analytics view in the Cloudflare dashboard. + * + * The dashboard filters by site tag in the query string; without a tag + * the account-level list is the closest useful place to send someone. + */ +export function cloudflareDashboardUrl(accountId: string, siteTag: string): string | null { + if (!accountId) return null; + const base = `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/web-analytics`; + return siteTag ? `${base}/overview?siteTag~in=${encodeURIComponent(siteTag)}` : `${base}/sites`; +} + +class CloudflareProvider implements Provider { + readonly id = "cloudflare" as const; + readonly capabilities = CLOUDFLARE_CAPABILITIES; + + #config: CloudflareConfig; + + constructor(config: CloudflareConfig) { + this.#config = config; + } + + get #hosts(): string[] { + return [...new Set((this.#config.hosts ?? []).map(normalizeHost).filter(Boolean))]; + } + + get #slash(): TrailingSlash { + return this.#config.trailingSlash ?? "ignore"; + } + + dashboardUrl(): string | null { + return cloudflareDashboardUrl(this.#config.accountId, this.#config.siteTag); + } + + async validate(): Promise> { + // A real query against the configured site, not a token ping: the + // failure we most need to catch is a token that authenticates fine + // and cannot see this account's analytics. + const query = ` +query EmDashAnalyticsValidate($account: string!, $siteTag: string!) { + viewer { accounts(filter: { accountTag: $account }) { + ${RUM_DATASET}(limit: 1, filter: { siteTag: $siteTag, bot: 0 }) { count } + } } +}`.trim(); + const res = await this.#graphql(query, { + account: this.#config.accountId, + siteTag: this.#config.siteTag, + }); + if (!res.ok) return res; + const account = firstAccount(res.value); + if (!account) return failure("cfNoAccount"); + return { ok: true, value: true }; + } + + /** + * Sites this token can see, without asking for a broader scope. + * + * `GET /accounts/{id}/rum/site_info/list` is the obvious way to do this + * and it is 403 with Account Analytics Read — confirmed live. It needs + * Account Settings Read, which also grants "read access to account + * membership", and the plugin will not ask an operator for that just to + * fill a dropdown. Grouping the dataset itself gives the same list. + * + * The tradeoff, stated so the UI can say it: a site with no traffic in + * the window cannot appear here, so the settings page needs a way to + * type a tag in by hand. + */ + async discoverSites(range: DateRange): Promise> { + const query = ` +query EmDashAnalyticsSites($account: string!, $since: Date!, $until: Date!) { + viewer { accounts(filter: { accountTag: $account }) { + ${RUM_DATASET}( + limit: ${MAX_PAGE_SIZE} + filter: { date_geq: $since, date_leq: $until, bot: 0 } + orderBy: [count_DESC] + ) { count dimensions { siteTag requestHost } } + } } +}`.trim(); + const res = await this.#graphql(query, { + account: this.#config.accountId, + since: range.since, + until: range.until, + }); + if (!res.ok) return res; + const account = firstAccount(res.value); + if (!account) return failure("cfNoAccount"); + + const byTag = new Map(); + for (const row of asArray(account[RUM_DATASET])) { + if (!isRecord(row)) continue; + const dims = isRecord(row.dimensions) ? row.dimensions : {}; + const siteTag = typeof dims.siteTag === "string" ? dims.siteTag : null; + if (!siteTag) continue; + const host = typeof dims.requestHost === "string" ? normalizeHost(dims.requestHost) : ""; + const existing = byTag.get(siteTag) ?? { siteTag, hosts: [], pageviews: 0 }; + if (host && !existing.hosts.includes(host)) existing.hosts.push(host); + existing.pageviews = (existing.pageviews ?? 0) + numberAt(row, "count"); + byTag.set(siteTag, existing); + } + return { ok: true, value: [...byTag.values()].sort((a, b) => (b.pageviews ?? 0) - (a.pageviews ?? 0)) }; + } + + async retention(): Promise> { + const query = ` +query EmDashAnalyticsRetention($account: string!) { + viewer { accounts(filter: { accountTag: $account }) { + settings { ${RUM_DATASET} { notOlderThan maxDuration maxPageSize } } + } } +}`.trim(); + const res = await this.#graphql(query, { account: this.#config.accountId }); + if (!res.ok) return res; + const account = firstAccount(res.value); + if (!account) return failure("cfNoAccount"); + const settings = isRecord(account.settings) ? account.settings : {}; + const probe = isRecord(settings[RUM_DATASET]) ? (settings[RUM_DATASET] as Record) : null; + if (!probe) return failure("cfNoRetention"); + + // The API answers in seconds; days are what the rest of the plugin + // reasons in. + return { + ok: true, + value: { + notOlderThanDays: secondsToDays(probe.notOlderThan), + maxDurationDays: secondsToDays(probe.maxDuration), + maxPageSize: numberAt(probe, "maxPageSize") || MAX_PAGE_SIZE, + }, + }; + } + + async overview(range: DateRange): Promise> { + const guard = guardRange(range); + if (guard) return { ok: false, error: guard }; + + const { filter, decls, vars } = this.#filter(range); + const days = enumerateDays(range.since, range.until).length; + // `limit` truncates without an error, so the series limit must be + // comfortably above the number of days it can possibly return. + const seriesLimit = Math.min(MAX_PAGE_SIZE, Math.max(days * 2, 32)); + + const query = ` +query EmDashAnalyticsOverview(${decls}) { + viewer { accounts(filter: { accountTag: $account }) { + totals: ${RUM_DATASET}(limit: 1, filter: ${filter}) { + count sum { visits } avg { sampleInterval } + } + series: ${RUM_DATASET}(limit: ${seriesLimit}, filter: ${filter}, orderBy: [date_ASC]) { + count sum { visits } avg { sampleInterval } dimensions { date } + } + pages: ${RUM_DATASET}(limit: ${TOP_PATHS_LIMIT}, filter: ${filter}, orderBy: [count_DESC]) { + count sum { visits } avg { sampleInterval } dimensions { requestPath } + } + refs: ${RUM_DATASET}(limit: ${REFERRERS_LIMIT}, filter: ${filter}, orderBy: [sum_visits_DESC]) { + count sum { visits } dimensions { refererHost } + } + geo: ${RUM_DATASET}(limit: ${COUNTRIES_LIMIT}, filter: ${filter}, orderBy: [count_DESC]) { + count sum { visits } dimensions { countryName } + } + } } +}`.trim(); + + const res = await this.#graphql(query, vars); + if (!res.ok) return res; + return this.#parseOverview(res.value, seriesLimit); + } + + async paths(paths: string[], range: DateRange): Promise> { + const guard = guardRange(range); + if (guard) return { ok: false, error: guard }; + if (paths.length === 0) return { ok: true, value: [] }; + + const days = enumerateDays(range.since, range.until).length; + // `requestPath_in` matches the path exactly as the browser requested + // it, and a site that serves both spellings reports both: asking only + // for the normalized one misses every view of the other. + const asked = [...new Set(paths.flatMap(spellings))]; + // The alias groups by date AND path, so the response can hold + // asked paths x days rows. The plan set no limit here at all, which + // would have truncated silently at the default. + const groups = asked.length * days; + if (groups > MAX_PAGE_SIZE) { + return failure("cfTooManyGroups", { paths: asked.length, days, max: MAX_PAGE_SIZE }); + } + const limit = Math.min(MAX_PAGE_SIZE, groups + 10); + + const { filter, decls, vars } = this.#filter(range, { paths: true }); + const query = ` +query EmDashAnalyticsPaths(${decls}) { + viewer { accounts(filter: { accountTag: $account }) { + ${RUM_DATASET}(limit: ${limit}, filter: ${filter}) { + count sum { visits } avg { sampleInterval } dimensions { date requestPath } + } + } } +}`.trim(); + + const res = await this.#graphql(query, { ...vars, paths: asked }); + if (!res.ok) return res; + const account = firstAccount(res.value); + if (!account) return failure("cfNoAccount"); + + const rows = asArray(account[RUM_DATASET]); + if (rows.length >= limit) { + return failure("cfTruncated"); + } + + const byDay = new Map(); + for (const row of rows) { + if (!isRecord(row)) continue; + const dims = isRecord(row.dimensions) ? row.dimensions : {}; + const date = typeof dims.date === "string" ? dims.date : null; + const rawPath = typeof dims.requestPath === "string" ? dims.requestPath : null; + if (!date || rawPath === null) continue; + const path = normalizePath(rawPath, this.#slash); + const pageviews = numberAt(row, "count"); + const visits = numberAt(isRecord(row.sum) ? row.sum : undefined, "visits"); + const sampleInterval = sampleIntervalOf(row); + const key = `${date}|${path}`; + const acc = byDay.get(key); + if (acc) { + acc.pageviews += pageviews; + acc.visits += visits; + acc.sampleInterval = Math.max(acc.sampleInterval, sampleInterval); + } else { + byDay.set(key, { date, path, pageviews, visits, sampleInterval }); + } + } + const out = [...byDay.values()]; + return { ok: true, value: out }; + } + + /** + * Build the shared filter literal. + * + * `bot: 0` and the host list go on **every** query, not just some of + * them: a filter that differs between the totals alias and the series + * alias produces a chart whose columns do not add up to its header. + */ + #filter(range: DateRange, opts: { paths?: boolean } = {}) { + const hosts = this.#hosts; + const parts = [ + "siteTag: $siteTag", + "date_geq: $since", + "date_leq: $until", + "bot: 0", + "requestPath_notlike: $internal", + ]; + const decls = ["$account: string!", "$siteTag: string!", "$since: Date!", "$until: Date!", "$internal: string!"]; + const vars: Record = { + account: this.#config.accountId, + siteTag: this.#config.siteTag, + since: range.since, + until: range.until, + internal: INTERNAL_PATHS, + }; + + // Declaring an unused variable is a GraphQL error, so the host and + // path arguments are only mentioned when they are actually used. + if (hosts.length > 0) { + parts.push("requestHost_in: $hosts"); + decls.push("$hosts: [string!]"); + vars.hosts = hosts; + } + if (opts.paths) { + parts.push("requestPath_in: $paths"); + decls.push("$paths: [string!]"); + } + + return { filter: `{ ${parts.join(", ")} }`, decls: decls.join(", "), vars }; + } + + async #graphql(query: string, variables: Record): Promise> { + let response: Response; + try { + response = await this.#config.fetch(GRAPHQL_ENDPOINT, { + method: "POST", + headers: { + Authorization: `Bearer ${this.#config.apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + } catch (error) { + return failure("cfUnreachable", { detail: error instanceof Error ? error.message : String(error) }); + } + + if (!response.ok) { + return httpFailure(response.status); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return failure("cfNotJson"); + } + + const graphqlError = firstGraphqlError(payload); + if (graphqlError) return graphqlError; + + return { ok: true, value: payload }; + } + + #parseOverview(payload: unknown, seriesLimit: number): Result { + const account = firstAccount(payload); + if (!account) return failure("cfNoAccount"); + + const totalsRow = asArray(account.totals)[0]; + const totals = { + pageviews: numberAt(totalsRow, "count"), + visits: numberAt(isRecord(totalsRow) ? totalsRow.sum : undefined, "visits"), + sampleInterval: sampleIntervalOf(totalsRow), + }; + + const series: DailyRow[] = []; + for (const row of asArray(account.series)) { + if (!isRecord(row)) continue; + const dims = isRecord(row.dimensions) ? row.dimensions : {}; + const date = typeof dims.date === "string" ? dims.date : null; + if (!date) continue; + series.push({ + date, + pageviews: numberAt(row, "count"), + visits: numberAt(isRecord(row.sum) ? row.sum : undefined, "visits"), + sampleInterval: sampleIntervalOf(row), + }); + } + + const topPaths: PathRow[] = []; + for (const row of asArray(account.pages)) { + if (!isRecord(row)) continue; + const dims = isRecord(row.dimensions) ? row.dimensions : {}; + const raw = typeof dims.requestPath === "string" ? dims.requestPath : null; + if (raw === null) continue; + const path = normalizePath(raw, this.#slash); + if (isInternalPath(path)) continue; + topPaths.push({ + path, + pageviews: numberAt(row, "count"), + visits: numberAt(isRecord(row.sum) ? row.sum : undefined, "visits"), + sampleInterval: sampleIntervalOf(row), + }); + } + + return { + ok: true, + value: { + totals, + series, + topPaths: foldByKey(topPaths), + referrers: labelled(account.refs, "refererHost", DIRECT_REFERRER), + countries: labelled(account.geo, "countryName", ""), + truncated: + asArray(account.series).length >= seriesLimit || + asArray(account.pages).length >= TOP_PATHS_LIMIT || + asArray(account.refs).length >= REFERRERS_LIMIT || + asArray(account.geo).length >= COUNTRIES_LIMIT, + }, + }; + } +} + +/** + * Cloudflare reports direct traffic as an empty `refererHost`, and on + * one small site that was 20 of 24 visits. Rendering it as a blank row in the + * referrers table would look like a bug. + */ +export const DIRECT_REFERRER = "(direct)"; + +function httpFailure(status: number) { + if (status === 403) return failure("cfForbidden"); + if (status === 429) return failure("cfRateLimited"); + return failure("cfHttp", { status }); +} + +/** + * Turn a GraphQL error list into one sentence. + * + * "not authorized for that account" earns its own branch because it is the + * single most confusing failure this plugin can hit: a token carrying + * *Zone* Analytics Read answers `viewer { zones }` perfectly happily and + * fails every account-scoped query with that message, so the token looks + * valid and the plugin looks broken. Cost us a diagnostic detour on + * 2026-09-20; it should cost an operator one sentence. + */ +function firstGraphqlError(payload: unknown): { ok: false; error: string; problem?: Problem } | null { + if (!isRecord(payload)) return { ok: false, error: "Unexpected response shape" }; + const errors = payload.errors; + if (!Array.isArray(errors) || errors.length === 0) return null; + const first = errors[0]; + const message = isRecord(first) && typeof first.message === "string" ? first.message : "unknown error"; + if (message.includes("not authorized for that account")) return failure("cfZoneToken"); + return { ok: false, error: `GraphQL: ${message}` }; +} + +/** + * Refuse a window that would come back tenfold-quantized. + * + * The scheduler already clamps, so reaching this is a programming error + * rather than a configuration one — but silently returning sampled numbers + * as though they were exact is the failure this whole design exists to + * avoid, so the adapter refuses rather than trusting its caller. + */ +/** A normalized path and its other spelling: `/about/` and `/about`. The root has one. */ +function spellings(path: string): string[] { + const bare = path.replace(/\/+$/, ""); + return bare === "" ? ["/"] : [bare, `${bare}/`]; +} + +function guardRange(range: DateRange): string | null { + const span = daysBetween(range.since, range.until); + if (span < 0) return `Inverted range: ${range.since} is after ${range.until}`; + if (span > UNSAMPLED_WINDOW_DAYS) { + return `Refusing a ${span}-day window: Cloudflare serves any query starting more than ${UNSAMPLED_WINDOW_DAYS} days back from its ~10% sample, which would quantize every day in the result.`; + } + return null; +} + +function labelled(raw: unknown, dimension: string, emptyLabel: string): LabelledRow[] { + const out: LabelledRow[] = []; + for (const row of asArray(raw)) { + if (!isRecord(row)) continue; + const dims = isRecord(row.dimensions) ? row.dimensions : {}; + const value = dims[dimension]; + if (typeof value !== "string") continue; + out.push({ + label: value === "" ? emptyLabel : value, + visits: numberAt(isRecord(row.sum) ? row.sum : undefined, "visits"), + pageviews: numberAt(row, "count"), + }); + } + return out; +} + +/** + * Normalization can map two reported paths onto one, so their numbers have + * to be added rather than one of them silently winning. + */ +function foldByKey(rows: PathRow[]): PathRow[] { + const merged = new Map(); + for (const row of rows) { + const existing = merged.get(row.path); + if (!existing) { + merged.set(row.path, { ...row }); + continue; + } + existing.pageviews += row.pageviews; + existing.visits += row.visits; + existing.sampleInterval = Math.max(existing.sampleInterval, row.sampleInterval); + } + return [...merged.values()].sort((a, b) => b.pageviews - a.pageviews); +} + +function secondsToDays(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : 0; + return Math.floor(n / 86_400); +} + +/** Absent sampling metadata means "counted everything", which is the API's own default. */ +function sampleIntervalOf(row: unknown): number { + if (!isRecord(row)) return 1; + const avg = isRecord(row.avg) ? row.avg : undefined; + const n = numberAt(avg, "sampleInterval"); + return n > 0 ? n : 1; +} + +function firstAccount(payload: unknown): Record | null { + if (!isRecord(payload)) return null; + const data = isRecord(payload.data) ? payload.data : payload; + const viewer = isRecord(data.viewer) ? data.viewer : null; + const account = viewer ? asArray(viewer.accounts)[0] : undefined; + return isRecord(account) ? account : null; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function numberAt(value: unknown, key: string): number { + if (!isRecord(value)) return 0; + const n = value[key]; + return typeof n === "number" && Number.isFinite(n) ? n : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export type { Day }; diff --git a/packages/analytics/src/providers/demo.ts b/packages/analytics/src/providers/demo.ts new file mode 100644 index 0000000..70c8711 --- /dev/null +++ b/packages/analytics/src/providers/demo.ts @@ -0,0 +1,259 @@ +/** + * Generated numbers, for trying the plugin without a provider account and + * for building the UI against data that looks like a real site. + * + * It goes through the same sync, storage and join as a real provider, so + * what it shows is what the pipeline does, not a mock-up of it. Numbers + * are a pure function of (day, path): a closed day reads the same on every + * tick, which read-compare-write depends on to skip unchanged rows. + * + * It mirrors Cloudflare where that changes what the UI shows: today is + * provisional and sampled, closed days are exact, referrers use the same + * direct-traffic label, countries are ISO-2 codes. + */ + +import { normalizePath, type TrailingSlash } from "../index/paths.js"; +import { daysBetween, enumerateDays, utcDay, type Day } from "../sync/window.js"; +import { DIRECT_REFERRER } from "./cloudflare.js"; +import type { + DailyRow, + DateRange, + LabelledRow, + Overview, + PathDayRow, + PathRow, + Provider, + ProviderCapabilities, + Result, + Retention, + Site, +} from "./types.js"; + +export const DEMO_CAPABILITIES: ProviderCapabilities = { + batchPaths: "filter", + maxPathsPerQuery: 100, + meteredReads: false, + defaultInterval: "*/15 * * * *", + metrics: ["pageviews", "visits"], + breakdowns: ["referrers", "countries"], + // There is no sampling cliff to respect, so the first sync backfills a + // quarter of history and the pages have something to chart. + exactWindowDays: 90, + reportsSampling: true, +}; + +export interface DemoConfig { + /** Paths to attribute traffic to: the indexed entries, in practice. */ + listPaths: () => Promise; + trailingSlash?: TrailingSlash; + now?: () => Date; +} + +export function createDemoProvider(config: DemoConfig): Provider { + return new DemoProvider(config); +} + +const TOP_PATHS_LIMIT = 20; + +/** Page views on an average weekday at the anchor date. */ +const BASE_DAILY_VIEWS = 140; +const GROWTH_ANCHOR: Day = "2026-01-01"; +const GROWTH_PER_DAY = 0.0015; +/** Sunday first, as `getUTCDay()` counts. */ +const WEEKDAY_FACTOR = [0.55, 1.05, 1.1, 1.08, 1.02, 0.92, 0.6]; +/** Share of traffic that lands on paths which are no entry. */ +const UNINDEXED_SHARE = 0.12; +const UNINDEXED_PATHS = ["/campaign/autumn", "/old-url"]; +const TODAY_SAMPLE_INTERVAL = 2; + +const REFERRER_SHARES: Array<[string, number]> = [ + [DIRECT_REFERRER, 0.46], + ["google.com", 0.28], + ["github.com", 0.07], + ["linkedin.com", 0.06], + ["duckduckgo.com", 0.04], + ["bing.com", 0.03], + ["news.ycombinator.com", 0.03], + ["t.co", 0.03], +]; + +const COUNTRY_SHARES: Array<[string, number]> = [ + ["DE", 0.52], + ["AT", 0.11], + ["CH", 0.09], + ["US", 0.08], + ["NL", 0.04], + ["GB", 0.04], + ["FR", 0.03], + ["PL", 0.03], + ["IT", 0.03], + ["ES", 0.03], +]; + +interface Weighted { + path: string; + weight: number; +} + +class DemoProvider implements Provider { + readonly id = "demo" as const; + readonly capabilities = DEMO_CAPABILITIES; + + #config: DemoConfig; + #weights: Promise | undefined; + + constructor(config: DemoConfig) { + this.#config = config; + } + + async validate(): Promise> { + return { ok: true, value: true }; + } + + async discoverSites(): Promise> { + return { ok: true, value: [{ siteTag: "demo", hosts: [] }] }; + } + + async retention(): Promise> { + return { ok: true, value: { notOlderThanDays: 184, maxDurationDays: 184, maxPageSize: 10_000 } }; + } + + async overview(range: DateRange): Promise> { + const days = enumerateDays(range.since, range.until); + if (days.length === 0) return { ok: false, error: "The requested range is empty or inverted." }; + + const weights = await this.#pathWeights(); + const today = this.#today(); + + const series: DailyRow[] = []; + const byPath = new Map(); + for (const day of days) { + const sampleInterval = day === today ? TODAY_SAMPLE_INTERVAL : 1; + let pageviews = 0; + let visits = 0; + for (const { path, weight } of weights) { + const n = this.#numbers(day, path, weight); + pageviews += n.pageviews; + visits += n.visits; + const acc = byPath.get(path) ?? { path, pageviews: 0, visits: 0, sampleInterval: 1 }; + acc.pageviews += n.pageviews; + acc.visits += n.visits; + acc.sampleInterval = Math.max(acc.sampleInterval, sampleInterval); + byPath.set(path, acc); + } + series.push({ date: day, pageviews, visits, sampleInterval }); + } + + const pageviews = series.reduce((n, d) => n + d.pageviews, 0); + const visits = series.reduce((n, d) => n + d.visits, 0); + const topPaths = [...byPath.values()] + .filter((p) => p.pageviews > 0) + .sort((a, b) => b.pageviews - a.pageviews || a.path.localeCompare(b.path)) + .slice(0, TOP_PATHS_LIMIT); + + return { + ok: true, + value: { + totals: { pageviews, visits, sampleInterval: Math.max(...series.map((d) => d.sampleInterval)) }, + series, + topPaths, + referrers: split(REFERRER_SHARES, pageviews, visits), + countries: split(COUNTRY_SHARES, pageviews, visits), + truncated: false, + }, + }; + } + + async paths(paths: string[], range: DateRange): Promise> { + const weights = new Map((await this.#pathWeights()).map((w) => [w.path, w.weight])); + const today = this.#today(); + const out: PathDayRow[] = []; + for (const raw of paths) { + const path = normalizePath(raw, this.#config.trailingSlash); + const weight = weights.get(path); + if (weight === undefined) continue; + for (const day of enumerateDays(range.since, range.until)) { + const n = this.#numbers(day, path, weight); + if (n.pageviews === 0) continue; + out.push({ date: day, path, ...n, sampleInterval: day === today ? TODAY_SAMPLE_INTERVAL : 1 }); + } + } + return { ok: true, value: out }; + } + + #today(): Day { + return utcDay(this.#now()); + } + + #now(): Date { + return this.#config.now?.() ?? new Date(); + } + + /** + * One path's share of the site, fixed for the provider's lifetime. + * + * The home page leads, the rest fall off like real traffic does (a + * power law over a stable, hash-shuffled order), and two paths that are + * no entry take a fixed share so the join has something to miss. + */ + #pathWeights(): Promise { + this.#weights ??= (async () => { + const slash = this.#config.trailingSlash; + const listed = [...new Set((await this.#config.listPaths()).map((p) => normalizePath(p, slash)))]; + const ranked = listed.includes("/") ? ["/"] : []; + ranked.push(...listed.filter((p) => p !== "/").sort((a, b) => unit(`rank|${a}`) - unit(`rank|${b}`))); + if (ranked.length === 0) ranked.push("/"); + + const raw = ranked.map((path, i) => ({ path, weight: 1 / (i + 1) ** 1.1 })); + const sum = raw.reduce((n, w) => n + w.weight, 0); + const indexed = raw.map((w) => ({ path: w.path, weight: (w.weight / sum) * (1 - UNINDEXED_SHARE) })); + const unindexed = UNINDEXED_PATHS.map((p, i) => ({ + path: normalizePath(p, slash), + weight: (UNINDEXED_SHARE * (UNINDEXED_PATHS.length - i)) / 3, + })); + return [...indexed, ...unindexed]; + })(); + return this.#weights; + } + + #numbers(day: Day, path: string, weight: number): { pageviews: number; visits: number } { + const full = dayVolume(day) * weight * (0.7 + 0.6 * unit(`${day}|${path}`)); + const pageviews = Math.round(full * this.#elapsedShare(day)); + const visits = Math.round(pageviews * (0.6 + 0.2 * unit(`visits|${day}|${path}`))); + return { pageviews, visits }; + } + + /** Today has only had the hours that passed; every other day is whole. */ + #elapsedShare(day: Day): number { + const now = this.#now(); + if (day !== utcDay(now)) return 1; + const midnight = Date.parse(`${day}T00:00:00.000Z`); + return (now.getTime() - midnight) / 86_400_000; + } +} + +function dayVolume(day: Day): number { + const weekday = new Date(`${day}T00:00:00.000Z`).getUTCDay(); + const growth = 1 + GROWTH_PER_DAY * daysBetween(GROWTH_ANCHOR, day); + const noise = 0.85 + 0.3 * unit(`day|${day}`); + return BASE_DAILY_VIEWS * WEEKDAY_FACTOR[weekday]! * Math.max(growth, 0.2) * noise; +} + +function split(shares: Array<[string, number]>, pageviews: number, visits: number): LabelledRow[] { + return shares + .map(([label, share]) => ({ label, pageviews: Math.round(pageviews * share), visits: Math.round(visits * share) })) + .filter((row) => row.visits > 0 || row.pageviews > 0); +} + +/** A stable number in [0, 1) for a key: FNV-1a, then a mulberry32 mix. */ +function unit(key: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + h ^= key.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + let t = (h + 0x6d2b79f5) | 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296; +} diff --git a/packages/analytics/src/providers/types.ts b/packages/analytics/src/providers/types.ts new file mode 100644 index 0000000..e2bac0e --- /dev/null +++ b/packages/analytics/src/providers/types.ts @@ -0,0 +1,156 @@ +/** + * The provider seam. + * + * Small on purpose, and about *cost* rather than endpoints: the scheduler + * reads cadence, batching strategy and caps from `capabilities` and never + * learns which vendor it is talking to. Nothing provider-shaped reaches + * storage or a Block Kit block. + * + * There is no registry and no loader. With one adapter today and a second + * planned, a `switch` on the configured provider id is the right amount of + * abstraction; anything more is scaffolding for a future that may not + * arrive in that shape. + */ + +import type { Problem } from "../i18n.js"; +import type { Day } from "../sync/window.js"; + +export type ProviderId = "cloudflare" | "demo"; + +export type Metric = "pageviews" | "visits" | "uniques"; +export type Breakdown = "referrers" | "countries" | "devices"; + +export interface ProviderCapabilities { + /** How the provider answers "numbers for these specific paths". */ + batchPaths: "filter" | "wide-pull" | "none"; + /** + * Most paths worth putting in one batched request. + * + * For Cloudflare this is *not* an API limit — `requestPath_in` was + * accepted at 10 000 entries in a 209 KB body on 2026-09-20. The real + * ceiling is the response: the per-path query groups by date *and* + * path, and `maxPageSize` is 10 000 groups, so paths x days must stay + * under that. Below it the binding constraints are ours, not the + * vendor's — the D1 write budget and Workers CPU. + */ + maxPathsPerQuery: number; + /** True when reading the API bills against the same quota as traffic. */ + meteredReads: boolean; + /** Cron expression the scheduler uses unless the operator overrides it. */ + defaultInterval: string; + /** Metrics this provider genuinely has. Cloudflare has no uniques. */ + metrics: ReadonlyArray; + breakdowns: ReadonlyArray; + /** + * How far back a query's start may reach and still return exact counts. + * + * Cloudflare serves unsampled beacon data for seven days and a ~10 % + * aggregate beyond that, and picks one source per query from the oldest + * day requested. A window that reaches one day too far returns + * tenfold-quantized numbers for *every* day in it. See + * `UNSAMPLED_WINDOW_DAYS`. + */ + exactWindowDays: number; + /** True when the provider reports how heavily a response was sampled. */ + reportsSampling: boolean; +} + +/** One day of site-wide numbers. */ +export interface DailyRow { + date: Day; + pageviews: number; + visits: number; + uniques?: number; + /** 1 means every beacon was counted; above 1 the numbers are estimates. */ + sampleInterval: number; +} + +/** One path's numbers over the queried range. */ +export interface PathRow { + path: string; + pageviews: number; + visits: number; + sampleInterval: number; +} + +/** One day of one path's numbers — what the per-entry summaries are built from. */ +export interface PathDayRow extends PathRow { + date: Day; +} + +export interface LabelledRow { + label: string; + visits: number; + pageviews: number; +} + +export interface Overview { + /** Site totals for the range. */ + totals: { pageviews: number; visits: number; sampleInterval: number }; + /** One row per day that reported data. A missing day is missing, not zero. */ + series: DailyRow[]; + topPaths: PathRow[]; + referrers: LabelledRow[]; + countries: LabelledRow[]; + /** + * True when the response hit its `limit` and was silently truncated. + * Cloudflare caps returned groups without saying so. + */ + truncated: boolean; +} + +export interface Site { + siteTag: string; + /** Absent when discovered through analytics rather than site management. */ + siteToken?: string; + hosts: string[]; + /** Page views seen in the discovery window, when discovery reports them. */ + pageviews?: number; +} + +export interface Retention { + /** Oldest day the provider will answer for. */ + notOlderThanDays: number; + /** Widest span a single query may cover. */ + maxDurationDays: number; + /** Most groups one query may return. */ + maxPageSize: number; +} + +export interface DateRange { + since: Day; + until: Day; +} + +export type Result = { ok: true; value: T } | { ok: false; error: string; problem?: Problem }; + +/** + * A provider adapter. + * + * Every method returns a `Result` rather than throwing: these run inside a + * cron tick that gets no retry and no backoff, so a failure has to be a + * value the scheduler can record and move past. + */ +export interface Provider { + readonly id: ProviderId; + readonly capabilities: ProviderCapabilities; + + /** A real query, so the error text can be specific about what is wrong. */ + validate(): Promise>; + + /** Sites this credential can actually see. */ + discoverSites(range: DateRange): Promise>; + + retention(): Promise>; + + overview(range: DateRange): Promise>; + + /** Per-day numbers for specific paths, batched into one request. */ + paths(paths: string[], range: DateRange): Promise>; + + /** The provider's own dashboard for this site, when it has one to link to. */ + dashboardUrl?(): string | null; +} + +/** The injected transport. `ctx.http.fetch` satisfies it. */ +export type FetchLike = (url: string, init?: RequestInit) => Promise; diff --git a/packages/analytics/src/settings.ts b/packages/analytics/src/settings.ts new file mode 100644 index 0000000..8cfaed3 --- /dev/null +++ b/packages/analytics/src/settings.ts @@ -0,0 +1,117 @@ +/** + * Reading the plugin's configuration. + * + * Everything comes out of `ctx.settings.list()` in **one** call rather + * than one `get()` per key. That is not micro-optimisation: a sandboxed + * plugin on Cloudflare gets ten subrequests per invocation and the bridge + * counts every `settings.get`, `kv.get` and `storage.*` call against that + * budget, so seven separate reads would spend most of a tick before the + * plugin had talked to anybody. + * + * `ctx.settings.get()` returns `null` for an unset key — defaults declared + * in `settingsSchema` are applied by the admin API when the form is saved, + * not when the value is read — so every default is applied again here. + */ + +import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { siteHosts } from "./index/paths.js"; +import type { ProviderId } from "./providers/types.js"; + +export interface AnalyticsSettings { + provider: ProviderId; + apiToken: string; + accountId: string; + siteTag: string; + hosts: string[]; + syncInterval: string; + retentionDays: number; + chunkSize: number; +} + +export const DEFAULT_SYNC_INTERVAL = "*/15 * * * *"; +export const RECONCILE_SCHEDULE = "10 3 * * *"; + +/** Cloudflare answers for 184 days; asking for more is a guaranteed gap. */ +const MAX_RETENTION_DAYS = 184; + +/** + * The most paths one paths tick may ask about, and the default. The tick + * spends seven bridge calls besides reading the stored daily rows, which + * leaves three `getMany` calls of 98 ids each: 36 paths over the eight + * days of the window. + */ +const MAX_CHUNK_SIZE = 36; + +export type SettingsResult = + | { ok: true; settings: AnalyticsSettings } + | { ok: false; missing: string[]; partial: Partial }; + +export async function readSettings(ctx: PluginContext): Promise { + const raw = new Map(); + for (const entry of await ctx.settings.list()) raw.set(entry.key, entry.value); + + const provider: ProviderId = raw.get("provider") === "demo" ? "demo" : "cloudflare"; + const apiToken = str(raw.get("cfApiToken")); + const accountId = str(raw.get("cfAccountId")); + const siteTag = str(raw.get("cfSiteTag")); + + const hosts = parseHosts(raw.get("hosts"), ctx.site.url); + const syncInterval = str(raw.get("syncInterval")) || DEFAULT_SYNC_INTERVAL; + const retentionDays = clampNumber(raw.get("retentionDays"), 7, MAX_RETENTION_DAYS, 90); + const chunkSize = clampNumber(raw.get("chunkSize"), 10, MAX_CHUNK_SIZE, MAX_CHUNK_SIZE); + + const partial = { provider, apiToken, accountId, siteTag, hosts, syncInterval, retentionDays, chunkSize }; + if (provider === "demo") return { ok: true, settings: partial }; + + // The site tag is deliberately not required: the settings form says it + // can be left empty, and the sync then lists the sites that have + // traffic. A token and an account are the irreducible minimum. + const missing: string[] = []; + if (!apiToken) missing.push("cfApiToken"); + if (!accountId) missing.push("cfAccountId"); + if (missing.length > 0) return { ok: false, missing, partial }; + + return { ok: true, settings: partial }; +} + +/** + * Which hostnames count as this site. + * + * Empty means "derive from the site URL", because one Cloudflare site tag + * routinely covers more than the canonical host — a tag for `example.com` + * typically also matches `www.example.com` and a `pages.dev` preview domain, and + * counting preview deploys as production traffic is exactly the sort of + * quiet wrongness that makes people distrust the numbers. + */ +function parseHosts(raw: unknown, siteUrl: string): string[] { + if (typeof raw === "string" && raw.trim()) { + return [...new Set(raw.split(",").map((h) => h.trim().toLowerCase()).filter(Boolean))]; + } + return siteHosts(siteUrl); +} + +function str(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +/** + * The operator-facing sentence for an incomplete configuration. + * + * Every failure mode that would otherwise render a dashboard of zeroes + * should read as one sentence naming the thing to fix. + */ +export function missingSettingsMessage(missing: string[]): string { + const labels: Record = { + cfApiToken: "an API token with Account → Account Analytics → Read", + cfAccountId: "the Cloudflare account ID", + }; + const parts = missing.map((key) => labels[key] ?? key); + return `Analytics is not configured yet: add ${listPhrase(parts)} in the plugin's settings.`; +} + +function listPhrase(parts: string[]): string { + if (parts.length <= 1) return parts[0] ?? ""; + return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`; +} diff --git a/packages/analytics/src/store/access.ts b/packages/analytics/src/store/access.ts new file mode 100644 index 0000000..b9cb583 --- /dev/null +++ b/packages/analytics/src/store/access.ts @@ -0,0 +1,114 @@ +/** + * Typed access to the declared storage collections. + * + * `PluginContext["storage"]` maps every declared collection to + * `StorageCollection`, and its methods are not generic, so the + * document type has to be asserted somewhere. Doing it once here keeps + * the assertion in a single reviewable place instead of scattering `as` + * through the sync job, and gives the rest of the plugin an honest typed + * surface. + * + * Each collection is also optional at runtime: a plugin whose manifest + * declares no storage gets nothing, and an operator running an older + * manifest may be missing one. Callers get `null` and decide, rather than + * a `TypeError` inside a cron tick that has no retry. + */ + +import type { PluginContext } from "emdash/plugin"; + +import type { DailyRow, EntryRow, RollupRow } from "./rows.js"; + +export interface Page { + items: Array<{ id: string; data: T }>; + cursor?: string; + hasMore: boolean; +} + +export interface QuerySpec { + where?: Record; + orderBy?: Record; + limit?: number; + cursor?: string; +} + +export interface Typed { + get(id: string): Promise; + getMany(ids: string[]): Promise>; + put(id: string, data: T): Promise; + putMany(items: Array<{ id: string; data: T }>): Promise; + deleteMany(ids: string[]): Promise; + query(spec?: QuerySpec): Promise>; +} + +/** Storage clamps a query's `limit` to 100 rows. */ +export const BIND_LIMIT = 100; + +/** + * Most ids one `getMany` or `deleteMany` may carry. + * + * D1 binds at most 100 parameters per statement, and storage builds one + * unchunked `IN (...)` that also binds the plugin id and the collection + * name. So 98, not 100: a full page from `query()` is two ids too many. + * The wrapper below splits every call, so callers cannot get this wrong. + */ +export const ID_BATCH = BIND_LIMIT - 2; + +export function chunk(items: T[], size: number = ID_BATCH): T[][] { + if (size < 1) return items.length > 0 ? [items] : []; + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +function wrap(raw: unknown): Typed | null { + if (!raw || typeof raw !== "object") return null; + const col = raw as { + get(id: string): Promise; + getMany(ids: string[]): Promise>; + put(id: string, data: unknown): Promise; + putMany(items: Array<{ id: string; data: unknown }>): Promise; + deleteMany(ids: string[]): Promise; + query(spec?: unknown): Promise<{ items: Array<{ id: string; data: unknown }>; cursor?: string; hasMore: boolean }>; + }; + + return { + async get(id) { + const value = await col.get(id); + return (value ?? undefined) as T | undefined; + }, + async getMany(ids) { + const out = new Map(); + for (const slice of chunk(ids)) { + for (const [id, value] of await col.getMany(slice)) out.set(id, value as T); + } + return out; + }, + put(id, data) { + return col.put(id, data); + }, + putMany(items) { + return col.putMany(items); + }, + async deleteMany(ids) { + let deleted = 0; + for (const slice of chunk(ids)) deleted += await col.deleteMany(slice); + return deleted; + }, + async query(spec) { + const page = await col.query(spec); + return page as Page; + }, + }; +} + +export function dailyStore(ctx: PluginContext): Typed | null { + return wrap(ctx.storage?.daily); +} + +export function rollupStore(ctx: PluginContext): Typed | null { + return wrap(ctx.storage?.rollup); +} + +export function entriesStore(ctx: PluginContext): Typed | null { + return wrap(ctx.storage?.entries); +} diff --git a/packages/analytics/src/store/rows.ts b/packages/analytics/src/store/rows.ts new file mode 100644 index 0000000..9ac2d00 --- /dev/null +++ b/packages/analytics/src/store/rows.ts @@ -0,0 +1,172 @@ +/** + * Stored row shapes and the one decision that guards every write. + * + * Pure, because `decideWrite` is where two separate findings meet and + * both are easy to get quietly wrong: + * + * - **Write budget** (review 1.3). D1's free plan allows 100 000 written + * rows per day, and plugin storage writes the row plus one more per + * declared index — so roughly 5 rows for a `daily` put and 8 for an + * `entries` put. Re-writing an unchanged chunk every 15 minutes blows + * through the daily budget by more than 2x. Writing only rows whose + * numbers actually changed brings a quiet site down to a handful. + * - **Freezing** (measured 2026-09-20). Cloudflare stops being able to + * reproduce exact numbers after seven days. A row read while it was + * still exact is better data than anything the provider can return + * later, so once frozen it is never overwritten. + */ + +import { daysBetween, isFrozen, isProvisional, type Day } from "../sync/window.js"; + +/** One day of one path. `id` is `|`. */ +export interface DailyRow { + date: Day; + path: string; + pageviews: number; + visits: number; + /** 1 means Cloudflare counted every beacon. Above 1 the row is an estimate. */ + sampleInterval: number; + /** ISO timestamp of the read that produced this row. */ + fetchedAt: string; +} + +/** Site totals for one day. `id` is the date. */ +export interface RollupRow { + date: Day; + pageviews: number; + visits: number; + sampleInterval: number; + fetchedAt: string; +} + +/** The path -> entry join. `id` is the normalized path. */ +export interface EntryRow { + path: string; + collection: string; + entryId: string; + translationGroup: string; + locale: string; + title: string; + /** + * `moved`: the entry is published at another path now, after a slug or + * URL pattern change. The row stays so the old URL's history does too. + */ + status: "published" | "unpublished" | "deleted" | "moved"; + /** When the entry was first published, if the content API said. */ + publishedAt?: string; + /** Page views over the last seven days, today included. Written by the paths tick. */ + views7: number; + /** + * Page views over the last 30 days, today included: `olderViews` plus + * `recentViews`. Stored rather than computed on read because the content + * page sorts by it, and storage sorts only on a stored, indexed field. + */ + views30: number; + /** Page views over the provider's exact window, `today - 7` to today, from the latest paths tick. */ + recentViews?: number; + /** Page views from the store over `today - 29` to `today - 8`, summed once a day. */ + olderViews?: number; + /** The day `olderViews` was summed for. */ + olderDay?: Day; + updatedAt: string; +} + +export const DAILY_ID_SEPARATOR = "|"; + +export function dailyId(date: Day, path: string): string { + return `${date}${DAILY_ID_SEPARATOR}${path}`; +} + +/** + * Split a `daily` id back into its parts. + * + * The path may itself contain the separator — nothing forbids a `|` in a + * URL path — so the split is on the first occurrence only. + */ +export function parseDailyId(id: string): { date: Day; path: string } | null { + const i = id.indexOf(DAILY_ID_SEPARATOR); + if (i < 0) return null; + return { date: id.slice(0, i), path: id.slice(i + 1) }; +} + +export type WriteDecision = + /** Numbers changed and the row is still writable. */ + | { action: "write" } + /** The stored row is exact history; the incoming one is not better. */ + | { action: "skip"; reason: "frozen" } + /** Identical numbers. Writing would cost D1 rows and change nothing. */ + | { action: "skip"; reason: "unchanged" } + /** Never replace an exact reading with an estimate. */ + | { action: "skip"; reason: "would-downgrade" }; + +/** + * Should this freshly fetched row be written over what is stored? + * + * Order matters. "Frozen" is checked before "unchanged" so the reason + * reported is the meaningful one, and the downgrade guard sits in front + * of the equality check because a sampled row may happen to carry the + * same numbers as the exact one it would replace. + */ +export function decideWrite( + existing: DailyRow | RollupRow | undefined, + incoming: DailyRow | RollupRow, + today: Day, + freezeAfterDays?: number, +): WriteDecision { + if (!existing) return { action: "write" }; + + if (isFrozen(existing.date, today, existing.sampleInterval, freezeAfterDays)) { + return { action: "skip", reason: "frozen" }; + } + + // An exact stored reading outranks a sampled fresh one regardless of + // age. This is the guard that makes an accidental wide query harmless + // rather than destructive. + if (existing.sampleInterval === 1 && incoming.sampleInterval > 1) { + return { action: "skip", reason: "would-downgrade" }; + } + + if ( + existing.pageviews === incoming.pageviews && + existing.visits === incoming.visits && + existing.sampleInterval === incoming.sampleInterval + ) { + return { action: "skip", reason: "unchanged" }; + } + + return { action: "write" }; +} + +/** + * Does stored history reach back to `day`? + * + * A comparison period the store covers only in part compares a few days + * against a whole period: after a backfill, one day against seven. Quiet + * days are absent rather than zero, so this can only ask whether the + * oldest stored day is early enough, and on a very quiet site it errs + * towards showing no comparison. + */ +export function historyReaches(rows: Array<{ date: Day }>, day: Day): boolean { + return rows.some((row) => daysBetween(row.date, day) >= 0); +} + +/** + * Sum the days of one path inside a window. + * + * Provisional days are included — leaving today out would make the widget + * look broken all morning — but the caller is told, so the number can be + * labelled as still moving. + */ +export function sumWindow(rows: Array<{ date: Day; pageviews: number; visits: number; sampleInterval: number }>, today: Day) { + let pageviews = 0; + let visits = 0; + let estimated = false; + let provisional = false; + for (const row of rows) { + pageviews += row.pageviews; + visits += row.visits; + if (row.sampleInterval > 1) estimated = true; + if (isProvisional(row.date, today)) provisional = true; + } + return { pageviews, visits, estimated, provisional }; +} diff --git a/packages/analytics/src/store/views.ts b/packages/analytics/src/store/views.ts new file mode 100644 index 0000000..fae0edd --- /dev/null +++ b/packages/analytics/src/store/views.ts @@ -0,0 +1,135 @@ +/** + * How an entry's 7- and 30-day view counts are put together. Pure. + * + * Thirty days cannot come from the provider: a Cloudflare query reaching + * back further than `today - 7` is served from a ~10 % sample for every + * day in it (see `UNSAMPLED_WINDOW_DAYS`). So `views30` has two parts: + * + * - **Recent**, `today - 7` to today: the window every paths tick fetches + * exactly anyway, summed as it arrives. `views7` is the last seven of + * those eight days. + * - **Older**, `today - 29` to `today - 8`: days the provider can no longer + * reproduce and nothing writes any more, so their sum only changes when + * the date does. A pass over the stored `daily` rows computes it once + * per UTC day (`sync/older.ts`). + * + * Summing all thirty days from the store on every tick would need about + * thirty `getMany` ids per path, which a ten-call sandbox budget cannot + * pay for more than a few paths at a time. + */ + +import { addDays, daysBetween, UNSAMPLED_WINDOW_DAYS, type Day } from "../sync/window.js"; +import type { EntryRow } from "./rows.js"; + +export const VIEWS7_DAYS = 7; +export const VIEWS30_DAYS = 30; + +/** The days the older part covers on `today`: from `today - 29` to `today - 8`. */ +export function olderWindow(today: Day): { since: Day; until: Day } { + return { + since: addDays(today, -(VIEWS30_DAYS - 1)), + until: addDays(today, -(UNSAMPLED_WINDOW_DAYS + 1)), + }; +} + +/** + * `views7` and the recent part of `views30` from one path's fetched rows. + * + * Rows outside the recent window are ignored rather than trusted: the + * caller's window is the adapter's business, and a day counted twice + * would be hard to spot in a sum. + */ +export function recentViewsOf(rows: Array<{ date: Day; pageviews: number }>, today: Day): { views7: number; recent: number } { + let views7 = 0; + let recent = 0; + for (const row of rows) { + const age = daysBetween(row.date, today); + if (age < 0 || age > UNSAMPLED_WINDOW_DAYS) continue; + recent += row.pageviews; + if (age < VIEWS7_DAYS) views7 += row.pageviews; + } + return { views7, recent }; +} + +/** + * The entry after a paths tick, or null when nothing changed. + * + * `olderSettled` says today's older pass has finished. An entry it did not + * reach then had no stored traffic in the older window, so its older part + * is zero, however large it was yesterday. Until the pass finishes, + * yesterday's older part stands in: off by at most two days' views for a + * few ticks, where zero would drop 22 days' worth. + */ +export function withRecent( + entry: EntryRow, + fetched: { views7: number; recent: number }, + today: Day, + olderSettled: boolean, +): EntryRow | null { + const stale = entry.olderDay !== today; + const olderViews = stale && olderSettled ? 0 : (entry.olderViews ?? 0); + const olderDay = stale && olderSettled ? today : entry.olderDay; + const views30 = olderViews + fetched.recent; + + if ( + entry.views7 === fetched.views7 && + entry.recentViews === fetched.recent && + (entry.olderViews ?? 0) === olderViews && + entry.views30 === views30 + ) { + return null; + } + + return { + ...entry, + views7: fetched.views7, + recentViews: fetched.recent, + olderViews, + ...(olderDay !== undefined && { olderDay }), + views30, + }; +} + +/** + * The entry after the older pass summed `older` for it on `day`, or null + * when nothing changed. + * + * The day is written even when the number is the same, because it is what + * tells the paths tick the entry was reached today (see `withRecent`). + */ +export function withOlder(entry: EntryRow, older: number, day: Day): EntryRow | null { + if (entry.olderViews === older && entry.olderDay === day) return null; + return { ...entry, olderViews: older, olderDay: day, views30: older + (entry.recentViews ?? 0) }; +} + +/** + * Per-path sums from `daily` rows read in path order, one page at a time. + * + * A path's rows can straddle two pages, so the last path of a page that + * has more after it is carried into the next call instead of being + * reported with half its days. The carry is plain data: the pass stores + * it with its cursor, so the next tick can pick it up. + */ +export function foldByPath( + rows: Array<{ path: string; pageviews: number }>, + carry: { path: string; views: number } | undefined, + hasMore: boolean, +): { finished: Map; carry: { path: string; views: number } | undefined } { + const finished = new Map(); + let current = carry ? { ...carry } : undefined; + + for (const row of rows) { + if (current && current.path === row.path) { + current.views += row.pageviews; + continue; + } + if (current) finished.set(current.path, (finished.get(current.path) ?? 0) + current.views); + current = { path: row.path, views: row.pageviews }; + } + + if (current && !hasMore) { + finished.set(current.path, (finished.get(current.path) ?? 0) + current.views); + current = undefined; + } + return { finished, carry: current }; +} diff --git a/packages/analytics/src/sync/older.ts b/packages/analytics/src/sync/older.ts new file mode 100644 index 0000000..93e09c7 --- /dev/null +++ b/packages/analytics/src/sync/older.ts @@ -0,0 +1,113 @@ +/** + * The once-a-day pass that sums the older part of `views30`. + * + * It walks the stored `daily` rows of the older window (`today - 29` to + * `today - 8`, see `store/views.ts`) in path order, sums each path, and + * writes the sums into the matching `entries` rows. Rows in that window + * are never rewritten, because the paths tick only fetches the last eight + * days, so the sums hold for the rest of the UTC day. + * + * The walk is sized to the budget it is given and resumes from a stored + * cursor. A path whose rows straddle two pages is carried to the next + * page, or to the next tick, before its sum is written, so a sum is never + * written with half its days. Each write is absolute rather than a delta, + * so a tick that dies after writing and is repeated writes the same + * numbers again. + * + * What it cannot see is an entry with no rows in the window at all. Those + * keep yesterday's number until the paths tick reaches them and, seeing + * the pass finished, sets their older part to zero (`withRecent`). + */ + +import type { PluginContext } from "emdash/plugin"; + +import { dailyStore, entriesStore, BIND_LIMIT, ID_BATCH } from "../store/access.js"; +import type { EntryRow } from "../store/rows.js"; +import { foldByPath, olderWindow, withOlder } from "../store/views.js"; +import type { Day } from "./window.js"; + +/** Where the pass for one UTC day stands. */ +export interface OlderPass { + day: Day; + complete: boolean; + cursor?: string; + carry?: { path: string; views: number }; +} + +export function olderDue(pass: OlderPass | undefined, today: Day): boolean { + return !pass || pass.day !== today || !pass.complete; +} + +/** True once today's pass has reached every path with stored older traffic. */ +export function olderSettled(pass: OlderPass | undefined, today: Day): boolean { + return pass?.day === today && pass.complete; +} + +/** + * One step of today's pass, spending at most `budget` bridge calls on + * storage: its `daily` pages, one `getMany` per 98 finished paths, and a + * `putMany` if anything changed. + */ +export async function runOlderStep( + ctx: PluginContext, + previous: OlderPass | undefined, + today: Day, + budget: number, +): Promise<{ pass: OlderPass; written: number }> { + const daily = dailyStore(ctx); + const entries = entriesStore(ctx); + if (!daily || !entries) return { pass: { day: today, complete: true }, written: 0 }; + + // Yesterday's unfinished pass is abandoned: its sums are for a window + // that has moved on. + const start = previous && previous.day === today && !previous.complete ? previous : { day: today, complete: false }; + const { since, until } = olderWindow(today); + + const finished = new Map(); + let carry = start.carry; + let cursor = start.cursor; + let done = false; + let spent = 0; + + // Another page is read only if the writes it could cause still fit: up to + // a hundred more finished paths, so one or two more `getMany`s, and the + // `putMany`. + while (!done && spent + 1 + Math.ceil((finished.size + BIND_LIMIT) / ID_BATCH) + 1 <= budget) { + const page = await daily.query({ + where: { date: { gte: since, lte: until } }, + orderBy: { path: "asc" }, + limit: BIND_LIMIT, + ...(cursor ? { cursor } : {}), + }); + spent++; + done = !page.hasMore || !page.cursor; + const folded = foldByPath( + page.items.map((item) => item.data), + carry, + !done, + ); + for (const [path, views] of folded.finished) finished.set(path, (finished.get(path) ?? 0) + views); + carry = folded.carry; + cursor = done ? undefined : page.cursor; + } + + let written = 0; + if (finished.size > 0) { + const stored = await entries.getMany([...finished.keys()]); + const updates: Array<{ id: string; data: EntryRow }> = []; + for (const [path, views] of finished) { + const row = stored.get(path); + // Traffic on a path no entry claims (any more) has nowhere to go. + if (!row) continue; + const next = withOlder(row, views, today); + if (next) updates.push({ id: path, data: next }); + } + if (updates.length > 0) await entries.putMany(updates); + written = updates.length; + } + + const pass: OlderPass = done + ? { day: today, complete: true } + : { day: today, complete: false, ...(cursor && { cursor }), ...(carry && { carry }) }; + return { pass, written }; +} diff --git a/packages/analytics/src/sync/scheduler.ts b/packages/analytics/src/sync/scheduler.ts new file mode 100644 index 0000000..58dcaca --- /dev/null +++ b/packages/analytics/src/sync/scheduler.ts @@ -0,0 +1,763 @@ +/** + * The sync job. + * + * Three constraints decide the shape of this file, and none of them is + * obvious from the outside: + * + * 1. **Ten subrequests per sandboxed invocation.** Every `ctx` call + * crosses the bridge as one, `log` included, and the eleventh aborts + * the invocation. Each phase below is annotated with its call count, + * `tests/budget.test.ts` counts them, and no tick logs: a failure is + * recorded in the sync state, where the widget shows it. + * 2. **No retry, no backoff.** A recurring cron failure is logged and + * `next_run_at` simply advances. So every tick derives its work from + * stored state and never assumes the previous one ran. + * 3. **The sampling cliff.** A window whose `date_geq` reaches more than + * seven days back returns tenfold-quantized numbers for every day in + * it. `syncWindow` clamps; the adapter refuses; `decideWrite` refuses + * to downgrade. Three layers, because getting this wrong corrupts the + * store silently. + * + * The tick alternates between an overview phase and a paths phase rather + * than doing both, which is what keeps either side inside the budget. + * Work that needs no provider (walking the content, summing stored days) + * borrows paths slots rather than adding calls to one. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { failure, type Problem } from "../i18n.js"; +import { bootstrapIndex, INDEX_VERSION, type IndexCursor } from "../index/bootstrap.js"; +import { createCloudflareProvider } from "../providers/cloudflare.js"; +import { createDemoProvider } from "../providers/demo.js"; +import type { DateRange, Overview, Provider, ProviderId } from "../providers/types.js"; +import { chunk, dailyStore, entriesStore, rollupStore, BIND_LIMIT, ID_BATCH } from "../store/access.js"; +import { dailyId, decideWrite, type DailyRow, type EntryRow, type RollupRow } from "../store/rows.js"; +import { recentViewsOf, withRecent } from "../store/views.js"; +import { missingSettingsMessage, readSettings, type AnalyticsSettings } from "../settings.js"; +import { olderDue, olderSettled, runOlderStep, type OlderPass } from "./older.js"; +import { backfillWindow, syncWindow, utcDay, UNSAMPLED_WINDOW_DAYS, type Day } from "./window.js"; + +export const SYNC_TASK = "sync"; +export const RECONCILE_TASK = "reconcile"; +/** The one-shot tick a Refresh asks for. */ +export const REFRESH_TASK = "refresh"; +export const STATE_KEY = "state"; +/** When the dashboard first found the sync scheduled and never run (see `noteWaiting`). */ +export const WAITING_KEY = "waitingSince"; + +/** + * The paths phase asks for the whole unsampled window rather than a + * two-day overlap, because that makes `views7` exactly the sum of what + * the request returned — no extra storage reads, no accumulator to get + * wrong. It costs more ids to compare, which is what bounds the chunk. + */ +const PATHS_WINDOW_DAYS = UNSAMPLED_WINDOW_DAYS; + +/** Days the widget's cards and page table count, today included. */ +export const WIDGET_DAYS = 7; + +/** + * Days before today each overview re-reads. Late beacons would need two; + * the top-path snapshot has to span the widget's week, because the + * widget's table shows it under the weekly cards. Days that already froze + * are skipped on write, so the wider read writes no more rows. + */ +const OVERVIEW_DAYS_BACK = WIDGET_DAYS - 1; + +/** How many days of history the widget's chart shows. */ +export const CHART_DAYS = 30; + +export interface SyncState { + phase: "overview" | "paths"; + /** Keyset cursor into `entries`, so a pass resumes where it stopped. */ + cursor?: string; + /** Where the catch-up walk over pre-existing content continues. */ + index?: IndexCursor; + lastSync?: string; + lastError?: string; + /** The same failure as a catalogue key, so each reader sees it in their language. */ + lastProblem?: Problem; + lastErrorAt?: string; + /** + * The phase that failed. Only a success of that phase clears the error: + * a Refresh runs an overview out of turn, and clearing a paths failure + * there would hide it until the next paths tick fails again. + */ + lastErrorPhase?: SyncState["phase"]; + /** Snapshot for the widget, so rendering never queries the provider. */ + topPaths?: Array<{ path: string; pageviews: number; visits: number }>; + referrers?: Array<{ label: string; visits: number }>; + countries?: Array<{ label: string; visits: number }>; + /** First day of the window the referrer, country and top-path snapshot covers. */ + snapshotSince?: Day; + /** True when the last overview was served from Cloudflare's sample. */ + estimated?: boolean; + sitesHint?: string; + /** + * True once the catch-up walk over pre-existing content has finished. + * Until then the alternate phase indexes instead of fetching, because + * asking the provider about paths nobody has indexed returns nothing. + * It stays true while a later walk repairs the index. + */ + indexComplete?: boolean; + indexed?: number; + /** The `INDEX_VERSION` of the last walk that completed. An older one gets repaired. */ + indexVersion?: number; + /** + * Collection labels by slug, as the index walk last listed them. Kept + * here so the analytics page can name collections without spending a + * bridge call on the schema. + */ + collectionLabels?: Record; + /** Set by the content page's Rebuild button: walk the content again. */ + rebuild?: boolean; + /** Today's pass summing the older part of `views30`. */ + older?: OlderPass; + /** + * What the last non-overview slot did. A repair walk or the older pass + * takes every other slot, never two in a row, so view counts keep + * updating while they run. + */ + lastWork?: "index" | "older" | "paths"; + /** The provider whose numbers are in storage. */ + provider?: ProviderId; + /** True once the first overview has pulled the provider's exact window. */ + backfilled?: boolean; + /** Keyset cursor into `entries` while a provider switch resets views. */ + wipeCursor?: string; +} + +/** Referrer and country rows kept from each overview, for the analytics page. */ +const SNAPSHOT_ROWS = 10; + +/** + * Bridge calls a provider-switch wipe may spend on its batches: ten, less + * the state read, the settings read and the state write. A batch is a + * query and a delete or reset, so the loop only starts one with two left. + */ +const WIPE_CALLS = 7; + +/** Prune batches per reconcile run: a settings read, then a query and a delete per batch. */ +const PRUNE_BATCHES = 4; + +/** + * Bridge calls the older pass may spend on storage: ten, less the state + * read, the settings read and the state write. + */ +const OLDER_CALLS = 7; + +export interface SyncOutcome { + phase: SyncState["phase"]; + ok: boolean; + error?: string; + /** Rows actually written, as opposed to compared and skipped. */ + written: number; + skipped: number; + paths?: number; + problem?: Problem; +} + +/** + * Schedule both tasks, idempotently. + * + * Called from `plugin:activate`, from the content hooks and from the + * widget's first `page_load` — because `plugin:activate` is dispatched + * only by the admin Enable endpoint, and a plugin registered through + * `plugins: []` in the site config never gets one. That is the npm + * channel, which is the channel most installs use. `CronAccess.schedule` + * upserts on (plugin, task), so calling it repeatedly is free of + * consequence. + */ +export async function ensureScheduled(ctx: PluginContext, interval: string): Promise { + if (!ctx.cron) return; + try { + await ctx.cron.schedule(SYNC_TASK, { schedule: interval }); + await ctx.cron.schedule(RECONCILE_TASK, { schedule: "10 3 * * *" }); + } catch (error) { + ctx.log.warn("analytics: could not schedule sync", { error: String(error) }); + } +} + +/** + * Ask for a tick now instead of running one inside the admin request. + * + * A route has the same ten-call budget as a tick and still has to render + * afterwards, so it cannot afford a sync phase. The one-shot runs on the + * next scheduler pass: at once on Node, with the next Cron Trigger on + * Workers. It has its own name because scheduling a one-shot under + * `sync` would turn the recurring task into one that runs once. + * + * Returns false when the host runs no scheduler. + */ +export async function requestRefresh(ctx: PluginContext, now: Date): Promise { + if (!ctx.cron) return false; + await ctx.cron.schedule(REFRESH_TASK, { schedule: now.toISOString() }); + return true; +} + +/** + * Remember when the dashboard first found the sync scheduled and not yet + * run. The task list cannot tell: every dashboard visit re-schedules the + * task, which moves its next run, so a scheduler that never runs is + * measured from here. A key of its own, because a first tick may write the + * state between this read and this write. + */ +export async function noteWaiting(ctx: PluginContext, state: SyncState, now: Date): Promise { + if (state.lastSync) return; + if (await ctx.kv.get(WAITING_KEY)) return; + await ctx.kv.set(WAITING_KEY, now.toISOString()); +} + +export async function readState(ctx: PluginContext): Promise { + const state = await ctx.kv.get(STATE_KEY); + return state ?? { phase: "overview" }; +} + +export function buildProvider(ctx: PluginContext, settings: AnalyticsSettings): Provider | null { + if (settings.provider === "demo") { + return createDemoProvider({ + listPaths: async () => { + const page = await entriesStore(ctx)?.query({ orderBy: { path: "asc" }, limit: BIND_LIMIT }); + return (page?.items ?? []).filter((item) => item.data.status !== "deleted").map((item) => item.data.path); + }, + trailingSlash: ctx.site.trailingSlash, + }); + } + if (!ctx.http) return null; + const http = ctx.http; + return createCloudflareProvider({ + apiToken: settings.apiToken, + accountId: settings.accountId, + siteTag: settings.siteTag, + hosts: settings.hosts, + trailingSlash: ctx.site.trailingSlash, + fetch: (url, init) => http.fetch(url, init), + }); +} + +/** + * One tick. + * + * Overview phase: kv.get, settings.list, fetch, rollup.getMany, + * rollup.putMany, kv.set — six bridge calls. + * + * Paths phase: kv.get, settings.list, entries.query, fetch, up to three + * daily.getMany, daily.putMany, entries.putMany, kv.set — ten at most. + * + * The paths slot also carries the background work: the first index walk + * takes it outright, and a repair walk or a step of the daily older pass + * takes every other one. + * + * `overview` runs the overview phase whatever the alternation says: a + * Refresh is asked for to update the totals, which only that phase reads. + */ +export async function runSync( + ctx: PluginContext, + now: Date = new Date(), + options: { overview?: boolean } = {}, +): Promise { + const state = await readState(ctx); + const result = await readSettings(ctx); + + // Numbers from one provider must never be read as another's: demo data + // left behind after switching to Cloudflare would look like real + // traffic. So a switch clears storage before anything else happens, + // including before a missing credential stops the tick. + const target = result.ok ? result.settings.provider : (result.partial.provider ?? "cloudflare"); + const stored = state.provider ?? (state.lastSync ? "cloudflare" : undefined); + if (stored && stored !== target) return await runWipe(ctx, state, target, now); + + if (!result.ok) { + const problem: Problem = { key: "notConfigured", params: { missing: result.missing.join(",") } }; + return await fail(ctx, state, state.phase, missingSettingsMessage(result.missing), now, problem); + } + + const settings = result.settings; + const provider = buildProvider(ctx, settings); + if (!provider) { + const { error, problem } = failure("noNetwork"); + return await fail(ctx, state, state.phase, error, now, problem); + } + + // A site tag is optional in the form. Without one there is nothing to + // query, but the operator gets a usable hint instead of silence. Demo + // data has no sites to choose between. + if (!settings.siteTag && settings.provider !== "demo") { + return await suggestSite(ctx, provider, state, now); + } + + if (options.overview || state.phase !== "paths") return await runOverviewPhase(ctx, provider, state, now); + + // Catching up on content that predates the plugin comes first: the + // paths phase can only ask about paths the index already knows. + if (!state.indexComplete) return await runIndexPhase(ctx, state, now); + + if (state.lastWork !== "index" && state.lastWork !== "older") { + if (indexOutdated(state)) return await runIndexPhase(ctx, state, now); + if (olderDue(state.older, utcDay(now))) return await runOlderPhase(ctx, state, now); + } + + return await runPathsPhase(ctx, provider, settings, state, now); +} + +/** Does the stored index come from an older walk, or has a rebuild been asked for? */ +export function indexOutdated(state: SyncState): boolean { + return Boolean(state.rebuild) || (state.indexVersion ?? 1) < INDEX_VERSION; +} + +/** + * Ask for the content to be walked again. The walk runs in the sync's + * alternate slots, taking turns with the paths tick, and repairs rows in + * place without touching their view counts. + */ +/** + * Keep the stored collection labels current. The index walk records them + * as it goes, but a site whose walk finished before labels were stored, + * or that renamed a collection since, would otherwise keep showing slugs. + * The per-entry page lists the collections anyway and calls this; it + * writes only when a label changed. + */ +export async function rememberCollectionLabels( + ctx: PluginContext, + state: SyncState, + labels: Record, +): Promise { + const stored = state.collectionLabels ?? {}; + const same = + Object.keys(labels).length === Object.keys(stored).length && + Object.entries(labels).every(([slug, label]) => stored[slug] === label); + if (same || Object.keys(labels).length === 0) return; + await writeState(ctx, { ...(await readState(ctx)), collectionLabels: labels }); +} + +export async function requestRebuild(ctx: PluginContext): Promise { + const state = await readState(ctx); + const next = { ...state, rebuild: true, index: undefined }; + await writeState(ctx, next); + return next; +} + +async function runOverviewPhase( + ctx: PluginContext, + provider: Provider, + state: SyncState, + now: Date, +): Promise { + // The first overview reaches back as far as the provider stays exact, so + // a fresh install starts with that much history (Cloudflare: eight days, + // demo: ninety). + const exactDays = provider.capabilities.exactWindowDays; + const range = state.backfilled + ? syncWindow(now, OVERVIEW_DAYS_BACK, exactDays) + : backfillWindow(now, exactDays); + const res = await provider.overview(range); + if (!res.ok) return await fail(ctx, state, "overview", res.error, now, res.problem); + + const today = utcDay(now); + const { written, skipped } = await writeRollup(ctx, res.value, today, now); + + await writeState(ctx, { + ...state, + phase: "paths", + provider: provider.id, + backfilled: true, + lastSync: now.toISOString(), + ...clearedErrors(state, "overview"), + topPaths: res.value.topPaths.slice(0, 5).map((p) => ({ + path: p.path, + pageviews: p.pageviews, + visits: p.visits, + })), + referrers: res.value.referrers.slice(0, SNAPSHOT_ROWS).map((r) => ({ label: r.label, visits: r.visits })), + countries: res.value.countries.slice(0, SNAPSHOT_ROWS).map((c) => ({ label: c.label, visits: c.visits })), + snapshotSince: range.since, + estimated: res.value.totals.sampleInterval > 1, + }); + + return { phase: "overview", ok: true, written, skipped }; +} + +/** + * One small page of the catch-up walk. + * + * Calls: kv.get, settings.list, schema.listCollections, content.list, one + * getPublicUrl per entry (three), entries.getMany, entries.putMany, kv.set + * — ten. The cursor is saved with the state, after the page's rows. + */ +async function runIndexPhase(ctx: PluginContext, state: SyncState, now: Date): Promise { + let result; + try { + result = await bootstrapIndex(ctx, state.index, now); + } catch (error) { + // Recorded as done, so a repair walk that keeps failing still leaves + // every other slot to the paths tick. + const indexing = failure("indexingFailed", { detail: String(error) }); + return await fail(ctx, { ...state, lastWork: "index" }, "paths", indexing.error, now, indexing.problem); + } + + const finished = result.complete; + await writeState(ctx, { + ...state, + phase: "overview", + lastWork: "index", + lastSync: now.toISOString(), + ...clearedErrors(state, "paths"), + index: result.next, + indexComplete: Boolean(state.indexComplete) || finished, + indexed: (state.indexed ?? 0) + result.indexed, + ...(Object.keys(result.labels).length > 0 && { collectionLabels: result.labels }), + ...(finished && { indexVersion: INDEX_VERSION, rebuild: undefined }), + }); + + return { phase: "paths", ok: true, written: result.indexed + result.repaired, skipped: result.skipped }; +} + +/** + * One step of the pass that sums the older part of `views30`. + * + * Calls: kv.get, settings.list, then at most seven on storage (daily + * pages, entries.getMany, entries.putMany), kv.set. + */ +async function runOlderPhase(ctx: PluginContext, state: SyncState, now: Date): Promise { + let result; + try { + result = await runOlderStep(ctx, state.older, utcDay(now), OLDER_CALLS); + } catch (error) { + const summing = failure("summingFailed", { detail: String(error) }); + return await fail(ctx, { ...state, lastWork: "older" }, "paths", summing.error, now, summing.problem); + } + + await writeState(ctx, { + ...state, + phase: "overview", + lastWork: "older", + lastSync: now.toISOString(), + ...clearedErrors(state, "paths"), + older: result.pass, + }); + + return { phase: "paths", ok: true, written: result.written, skipped: 0 }; +} + +async function runPathsPhase( + ctx: PluginContext, + provider: Provider, + settings: AnalyticsSettings, + state: SyncState, + now: Date, +): Promise { + const entries = entriesStore(ctx); + const daily = dailyStore(ctx); + if (!entries || !daily) { + const { error, problem } = failure("storageUnavailable"); + return await fail(ctx, state, "paths", error, now, problem); + } + + const range = syncWindow(now, PATHS_WINDOW_DAYS); + + // Ordering by `path` rather than insertion order because `path` is a + // declared index and storage rejects an orderBy on anything else. It + // also makes the pass deterministic, which matters for a cursor that + // survives across ticks. + const page = await entries.query({ + orderBy: { path: "asc" }, + limit: settings.chunkSize, + ...(state.cursor ? { cursor: state.cursor } : {}), + }); + + const live = page.items.map((item) => item.data).filter((row) => row.status !== "deleted"); + const paths = [...new Set(live.map((row) => row.path))]; + + if (paths.length === 0) { + // Either no entries are indexed yet or the pass is complete; either + // way the next tick starts a fresh pass from the beginning. + await writeState(ctx, { + ...state, + phase: "overview", + lastWork: "paths", + cursor: undefined, + lastSync: now.toISOString(), + }); + return { phase: "paths", ok: true, written: 0, skipped: 0, paths: 0 }; + } + + const res = await provider.paths(paths, range); + if (!res.ok) return await fail(ctx, state, "paths", res.error, now, res.problem); + + const today = utcDay(now); + const fetched = new Map(); + for (const row of res.value) { + fetched.set(dailyId(row.date, row.path), { + date: row.date, + path: row.path, + pageviews: row.pageviews, + visits: row.visits, + sampleInterval: row.sampleInterval, + fetchedAt: now.toISOString(), + }); + } + + // Read before write. Without this the tick re-writes every row every + // 15 minutes, which on D1's free plan is roughly 2.2x the daily budget + // before the site's own writes are counted. + const ids = [...fetched.keys()]; + const existing = new Map(); + for (const slice of chunk(ids)) { + for (const [id, row] of await daily.getMany(slice)) existing.set(id, row); + } + + const toWrite: Array<{ id: string; data: DailyRow }> = []; + let skipped = 0; + for (const [id, row] of fetched) { + const decision = decideWrite(existing.get(id), row, today); + if (decision.action === "write") toWrite.push({ id, data: row }); + else skipped++; + } + if (toWrite.length > 0) await daily.putMany(toWrite); + + // views7 and the recent part of views30 are exactly the window just + // fetched, so they need no second read and cannot drift from the rows + // written above. + const perPath = new Map>(); + for (const row of res.value) { + const rows = perPath.get(row.path) ?? []; + rows.push(row); + perPath.set(row.path, rows); + } + + const settled = olderSettled(state.older, today); + const entryUpdates: Array<{ id: string; data: EntryRow }> = []; + for (const item of page.items) { + const next = withRecent(item.data, recentViewsOf(perPath.get(item.data.path) ?? [], today), today, settled); + if (next) entryUpdates.push({ id: item.id, data: next }); + } + if (entryUpdates.length > 0) await entries.putMany(entryUpdates); + + await writeState(ctx, { + ...state, + phase: "overview", + lastWork: "paths", + provider: provider.id, + cursor: page.cursor, + lastSync: now.toISOString(), + ...clearedErrors(state, "paths"), + }); + + return { phase: "paths", ok: true, written: toWrite.length, skipped, paths: paths.length }; +} + +/** + * `reconcile` prunes. It does not re-read anything from the provider. + * + * The plan originally had it re-pull the full retention window to correct + * drift. Measured against the live API, that would overwrite exact rows + * with numbers quantized to multiples of ten and silently drop every day + * too quiet to survive a 10 % sample. After seven days this store is the + * only place the real numbers still exist, so the daily job's one duty is + * to stop it growing without bound. + */ +export async function runReconcile( + ctx: PluginContext, + retentionDays: number, + now: Date = new Date(), +): Promise<{ deleted: number; more: boolean }> { + const daily = dailyStore(ctx); + if (!daily) return { deleted: 0, more: false }; + + const cutoff = utcDay(new Date(now.getTime() - retentionDays * 86_400_000)); + + let deleted = 0; + let more = true; + for (let i = 0; more && i < PRUNE_BATCHES; i++) { + // There is no delete-by-where, so pruning is query-then-delete in a + // bounded loop rather than one statement. A page of `ID_BATCH` rows + // is what one deleteMany can take. + const page = await daily.query({ where: { date: { lt: cutoff } }, limit: ID_BATCH }); + if (page.items.length > 0) deleted += await daily.deleteMany(page.items.map((item) => item.id)); + more = page.hasMore; + } + + return { deleted, more }; +} + +/** + * No site tag configured: name the sites this token can actually see. + * + * Discovery runs through the analytics dataset rather than + * `rum/site_info/list`, which needs Account Settings Read — a scope that + * also grants read access to account membership, and not something to ask + * for so a dropdown can be populated. + */ +async function suggestSite( + ctx: PluginContext, + provider: Provider, + state: SyncState, + now: Date, +): Promise { + const range: DateRange = { + since: utcDay(new Date(now.getTime() - 30 * 86_400_000)), + until: utcDay(now), + }; + const res = await provider.discoverSites(range); + + const found = res.ok + ? res.value.length > 0 + ? failure("noSiteTagSites", { + sites: res.value + .slice(0, 5) + .map((s) => `${s.siteTag}${s.hosts[0] ? ` (${s.hosts[0]})` : ""}`) + .join(", "), + }) + : failure("noSiteTagNoTraffic") + : res; + + await writeState(ctx, { + ...state, + lastError: found.error, + lastProblem: found.problem, + lastErrorAt: now.toISOString(), + lastErrorPhase: undefined, + sitesHint: found.error, + }); + return { phase: state.phase, ok: false, error: found.error, problem: found.problem, written: 0, skipped: 0 }; +} + +async function writeRollup(ctx: PluginContext, overview: Overview, today: Day, now: Date) { + const rollup = rollupStore(ctx); + if (!rollup) return { written: 0, skipped: 0 }; + + const incoming = new Map(); + for (const row of overview.series) { + incoming.set(row.date, { + date: row.date, + pageviews: row.pageviews, + visits: row.visits, + sampleInterval: row.sampleInterval, + fetchedAt: now.toISOString(), + }); + } + if (incoming.size === 0) return { written: 0, skipped: 0 }; + + const existing = new Map(); + for (const slice of chunk([...incoming.keys()])) { + for (const [id, row] of await rollup.getMany(slice)) existing.set(id, row); + } + + const toWrite: Array<{ id: string; data: RollupRow }> = []; + let skipped = 0; + for (const [id, row] of incoming) { + const decision = decideWrite(existing.get(id), row, today); + if (decision.action === "write") toWrite.push({ id, data: row }); + else skipped++; + } + if (toWrite.length > 0) await rollup.putMany(toWrite); + return { written: toWrite.length, skipped }; +} + +/** + * Clear the previous provider's numbers after a switch, in bounded batches. + * + * `rollup` and `daily` are emptied; `entries` keeps its rows, because the + * path-to-entry index describes the site's content rather than anybody's + * traffic, and only has its view counts reset. A large store takes several + * ticks; until the wipe completes, `state.provider` still names the old + * provider, so every tick resumes it. + */ +async function runWipe(ctx: PluginContext, state: SyncState, target: ProviderId, now: Date): Promise { + let calls = WIPE_CALLS; + let finished = true; + + for (const store of [rollupStore(ctx), dailyStore(ctx)]) { + if (!store) continue; + let more = true; + while (more && calls >= 2) { + const page = await store.query({ limit: ID_BATCH }); + calls--; + if (page.items.length > 0) { + await store.deleteMany(page.items.map((item) => item.id)); + calls--; + } + more = page.hasMore; + } + if (more) { + finished = false; + break; + } + } + + let cursor = state.wipeCursor; + const entries = entriesStore(ctx); + if (entries && finished) { + let more = true; + while (more && calls >= 2) { + const page = await entries.query({ orderBy: { path: "asc" }, limit: ID_BATCH, ...(cursor ? { cursor } : {}) }); + calls--; + const reset = page.items + .filter( + (item) => + item.data.views7 !== 0 || + item.data.views30 !== 0 || + Boolean(item.data.recentViews) || + Boolean(item.data.olderViews), + ) + .map((item) => ({ + id: item.id, + data: { ...item.data, views7: 0, views30: 0, recentViews: 0, olderViews: 0 }, + })); + if (reset.length > 0) { + await entries.putMany(reset); + calls--; + } + more = page.hasMore && Boolean(page.cursor); + cursor = more ? page.cursor : undefined; + } + if (more) finished = false; + } + + if (!finished) { + await writeState(ctx, { ...state, wipeCursor: cursor }); + return { phase: state.phase, ok: true, written: 0, skipped: 0 }; + } + + await writeState(ctx, { + phase: "overview", + provider: target, + index: state.index, + indexComplete: state.indexComplete, + indexed: state.indexed, + indexVersion: state.indexVersion, + rebuild: state.rebuild, + collectionLabels: state.collectionLabels, + }); + return { phase: "overview", ok: true, written: 0, skipped: 0 }; +} + +async function fail( + ctx: PluginContext, + state: SyncState, + phase: SyncState["phase"], + error: string, + now: Date, + problem?: Problem, +): Promise { + // Keep the last good numbers. Stale and labelled beats blank. + await writeState(ctx, { + ...state, + lastError: error, + lastProblem: problem, + lastErrorAt: now.toISOString(), + lastErrorPhase: phase, + }); + return { phase, ok: false, error, ...(problem && { problem }), written: 0, skipped: 0 }; +} + +/** The error fields a successful `phase` resets, or none if another phase failed. */ +function clearedErrors(state: SyncState, phase: SyncState["phase"]): Partial { + if (state.lastErrorPhase && state.lastErrorPhase !== phase) return {}; + return { lastError: undefined, lastProblem: undefined, lastErrorAt: undefined, lastErrorPhase: undefined }; +} + +async function writeState(ctx: PluginContext, state: SyncState): Promise { + await ctx.kv.set(STATE_KEY, state); +} diff --git a/packages/analytics/src/sync/window.ts b/packages/analytics/src/sync/window.ts new file mode 100644 index 0000000..22691bb --- /dev/null +++ b/packages/analytics/src/sync/window.ts @@ -0,0 +1,161 @@ +/** + * Date windows for the sync job. Pure, so the rules that matter can be + * tested without a provider. + * + * Everything here is UTC. Cloudflare's GraphQL API has no timezone + * concept — `date` is a UTC calendar day — while the Cloudflare dashboard + * renders in the viewer's local timezone. Mixing the two is the single + * easiest way to produce numbers that look wrong for reasons that have + * nothing to do with the plugin. + */ + +/** + * How far back `date_geq` may reach before Cloudflare switches the whole + * query to its aggregated sample. + * + * This is the most important number in the plugin. Measured against a live + * account on 2026-09-20, holding `date_leq` at today and walking `date_geq` + * backwards one day at a time: + * + * date_geq today-6 -> 25 page views, sampleInterval 1.47 + * date_geq today-7 -> 31 page views, sampleInterval 1.35 + * date_geq today-8 -> 10 page views, sampleInterval 10 + * date_geq today-9 -> 10 page views, sampleInterval 10 + * + * Widening the window by one single day cut the reported total from 31 to + * 10. Past the cliff every value is a multiple of ten, and days quiet + * enough to contribute no sampled row disappear from the response + * altogether — a day that reports 7 views in a one-day window is simply + * absent from any query starting eight days back. + * + * The sampling decision is made per query, from the oldest day requested, + * and then applied to every day in the result including today. So a wider + * window is not a cheaper request, it is a corrupt one, and no caller may + * widen it to save a round trip. + */ +export const UNSAMPLED_WINDOW_DAYS = 7; + +/** + * Days of overlap re-fetched on every tick. + * + * Beacon data arrives late, and today's row is never final anyway (see + * `isProvisional`), so each tick re-reads the last few days and lets the + * newer answer win until the day freezes. + */ +export const DEFAULT_OVERLAP_DAYS = 2; + +const MS_PER_DAY = 86_400_000; + +/** A UTC calendar day, `YYYY-MM-DD` — the format the API speaks. */ +export type Day = string; + +const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function isDay(value: unknown): value is Day { + return typeof value === "string" && DAY_PATTERN.test(value); +} + +/** The UTC calendar day containing `at`. */ +export function utcDay(at: Date): Day { + return at.toISOString().slice(0, 10); +} + +function dayToMs(day: Day): number { + const ms = Date.parse(`${day}T00:00:00.000Z`); + if (Number.isNaN(ms)) throw new RangeError(`Not a UTC day: ${day}`); + return ms; +} + +/** Shift a day by whole days. Negative moves backwards. */ +export function addDays(day: Day, delta: number): Day { + return utcDay(new Date(dayToMs(day) + delta * MS_PER_DAY)); +} + +/** Whole days from `from` to `to`; negative when `to` is earlier. */ +export function daysBetween(from: Day, to: Day): number { + return Math.round((dayToMs(to) - dayToMs(from)) / MS_PER_DAY); +} + +/** Every day in `[since, until]`, ascending and inclusive. */ +export function enumerateDays(since: Day, until: Day): Day[] { + const span = daysBetween(since, until); + if (span < 0) return []; + return Array.from({ length: span + 1 }, (_, i) => addDays(since, i)); +} + +export interface SyncWindow { + /** `date_geq` — never older than the unsampled cliff. */ + since: Day; + /** `date_leq` — today, in UTC. */ + until: Day; + /** True when the requested overlap had to be shortened to stay unsampled. */ + clamped: boolean; +} + +/** + * The window a tick may ask Cloudflare for. + * + * `overlapDays` is a request, not a promise: it is clamped to the + * provider's exact window (`UNSAMPLED_WINDOW_DAYS` for Cloudflare) because + * exceeding it silently corrupts every number in the response rather than + * merely returning more of them. + */ +export function syncWindow( + now: Date, + overlapDays: number = DEFAULT_OVERLAP_DAYS, + exactDays: number = UNSAMPLED_WINDOW_DAYS, +): SyncWindow { + const until = utcDay(now); + const wanted = Math.max(0, Math.trunc(overlapDays)); + const allowed = Math.min(wanted, Math.max(0, Math.trunc(exactDays))); + return { since: addDays(until, -allowed), until, clamped: allowed < wanted }; +} + +/** + * The widest window that is still exact, used by the first-run backfill. + * + * Anything older than this has to come from the sampled aggregate, so the + * backfill stops here and the UI says the store starts on this day rather + * than inventing tenfold-quantized history. + */ +export function backfillWindow(now: Date, exactDays: number = UNSAMPLED_WINDOW_DAYS): SyncWindow { + return syncWindow(now, exactDays, exactDays); +} + +/** + * Is a day still open, or recent enough that its numbers may still move? + * + * Today is always provisional: a one-day query for today reported + * `sampleInterval` 2.1 to 2.3 in the 2026-09-20 measurements, while every + * closed day inside the unsampled window reported exactly 1. So today is + * an estimate by construction, and the UI has to say so. + */ +export function isProvisional(day: Day, today: Day): boolean { + return daysBetween(day, today) <= 0; +} + +/** + * May a stored row for `day` be overwritten by a freshly fetched one? + * + * A day freezes once it is closed, old enough that late beacons have + * stopped arriving, and was last written from an unsampled response. + * Frozen rows are the plugin's exact history — after seven days + * Cloudflare itself can no longer reproduce them — so nothing overwrites + * one, least of all a sampled re-read. + */ +export function isFrozen(day: Day, today: Day, storedSampleInterval: number, freezeAfterDays: number = DEFAULT_OVERLAP_DAYS): boolean { + if (isProvisional(day, today)) return false; + if (daysBetween(day, today) < freezeAfterDays) return false; + return storedSampleInterval === 1; +} + +/** + * Is a response exact enough to be written as history? + * + * `sampleInterval` 1 means Cloudflare counted every beacon. Anything above + * 1 is an estimate: `count` is already scaled up by this factor, so the + * number is not raw, it is extrapolated. + */ +export function isExact(sampleInterval: number): boolean { + return sampleInterval === 1; +} diff --git a/packages/analytics/src/ui/blocks.ts b/packages/analytics/src/ui/blocks.ts new file mode 100644 index 0000000..bfa920f --- /dev/null +++ b/packages/analytics/src/ui/blocks.ts @@ -0,0 +1,227 @@ +/** + * The handful of Block Kit constructors this plugin needs. + * + * Why not `blocks`/`elements` from `@emdash-cms/blocks/server`? Because + * importing them puts the package in the *runtime* bundle, and the plugin + * build inlines everything with `external: []`, so a self-contained + * helper library would be carried into a bundle that has a 384 KiB gzip + * ceiling. With pnpm's symlinked store the probe build also failed to + * resolve it at all. + * + * The safety those builders provide is not the function calls, it is + * getting `block_id` / `action_id` / `empty_text` right — the renderer + * reads snake_case and silently ignores camelCase, which is the live bug + * in upstream's own `audit-log` widget, where "Load more" and the empty + * text never appear. That safety is preserved two ways here: + * + * 1. Every return type is upstream's own block interface, imported as a + * **type only** so it is erased before bundling. A wrong key is a + * compile error, not a silent no-op. + * 2. The tests run upstream's real `validateBlocks()` — the same function + * the host uses to reject a bad response — over what `renderWidget` + * actually produces. That is a stronger check than calling a builder + * and trusting it, and it is where the dependency belongs: in + * devDependencies. + */ + +import type { BannerBlock, ChartBlock, ChartSeries, CodeBlock } from "@emdash-cms/blocks"; +import type { + ActionElement, + ActionsBlock, + ButtonElement, + ColumnsBlock, + ContextBlock, + HeaderBlock, + LinkElement, + LinkTarget, + SelectElement, + StatItem, + StatsBlock, + TableBlock, + TableColumn, +} from "@emdash-cms/blocks/server"; + +export function stats(items: StatItem[], opts?: { blockId?: string }): StatsBlock { + return { + type: "stats", + items, + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function context(text: string, opts?: { blockId?: string }): ContextBlock { + return { + type: "context", + text, + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function actions(elements: ActionElement[], opts?: { blockId?: string }): ActionsBlock { + return { + type: "actions", + elements, + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function button( + actionId: string, + label: string, + opts?: { style?: "primary" | "danger" | "secondary"; value?: unknown }, +): ButtonElement { + return { + type: "button", + action_id: actionId, + label, + ...(opts?.style !== undefined && { style: opts.style }), + ...(opts?.value !== undefined && { value: opts.value }), + }; +} + +export function link( + label: string, + target: LinkTarget, + opts?: { appearance?: "inline" | "primary" | "secondary" }, +): LinkElement { + return { + type: "link", + label, + target, + ...(opts?.appearance !== undefined && { appearance: opts.appearance }), + }; +} + +export function select( + actionId: string, + label: string, + options: Array<{ label: string; value: string }>, + opts?: { initialValue?: string }, +): SelectElement { + return { + type: "select", + action_id: actionId, + label, + options, + ...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }), + }; +} + +export function banner(opts: { + title?: string; + description?: string; + variant?: "default" | "alert" | "error"; + blockId?: string; +}): BannerBlock { + return { + type: "banner", + ...(opts.title !== undefined && { title: opts.title }), + ...(opts.description !== undefined && { description: opts.description }), + ...(opts.variant !== undefined && { variant: opts.variant }), + ...(opts.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function code(text: string, opts?: { language?: CodeBlock["language"]; blockId?: string }): CodeBlock { + return { + type: "code", + code: text, + ...(opts?.language !== undefined && { language: opts.language }), + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function header(text: string, opts?: { blockId?: string }): HeaderBlock { + return { + type: "header", + text, + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function columns(cols: AnalyticsBlock[][], opts?: { blockId?: string }): ColumnsBlock { + return { + type: "columns", + columns: cols as ColumnsBlock["columns"], + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function timeseries( + series: ChartSeries[], + opts?: { blockId?: string; height?: number; style?: "line" | "bar"; gradient?: boolean }, +): ChartBlock { + return { + type: "chart", + config: { + chart_type: "timeseries", + series, + ...(opts?.style !== undefined && { style: opts.style }), + ...(opts?.height !== undefined && { height: opts.height }), + ...(opts?.gradient !== undefined && { gradient: opts.gradient }), + }, + ...(opts?.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +export function table(opts: { + blockId?: string; + columns: TableColumn[]; + rows: Array>; + pageActionId: string; + nextCursor?: string; + emptyText?: string; +}): TableBlock { + return { + type: "table", + columns: opts.columns, + rows: opts.rows, + page_action_id: opts.pageActionId, + ...(opts.nextCursor !== undefined && { next_cursor: opts.nextCursor }), + ...(opts.emptyText !== undefined && { empty_text: opts.emptyText }), + ...(opts.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +/** + * `empty` is not in the server export's published type list on 0.38.0, + * although the renderer and the validator both accept it. Typed locally + * against the validator's expectations rather than left out, because "no + * data yet, and here is why" is the most important thing this widget says + * on a fresh install. + */ +export interface EmptyBlockShape { + type: "empty"; + title?: string; + description?: string; + actions?: ActionElement[]; + block_id?: string; +} + +export function empty(opts: { + title?: string; + description?: string; + actions?: ActionElement[]; + blockId?: string; +}): EmptyBlockShape { + return { + type: "empty", + ...(opts.title !== undefined && { title: opts.title }), + ...(opts.description !== undefined && { description: opts.description }), + ...(opts.actions !== undefined && { actions: opts.actions }), + ...(opts.blockId !== undefined && { block_id: opts.blockId }), + }; +} + +/** Every block shape this plugin can emit. */ +export type AnalyticsBlock = + | StatsBlock + | ContextBlock + | ActionsBlock + | TableBlock + | EmptyBlockShape + | HeaderBlock + | ColumnsBlock + | ChartBlock + | BannerBlock + | CodeBlock; diff --git a/packages/analytics/src/ui/content.ts b/packages/analytics/src/ui/content.ts new file mode 100644 index 0000000..7996c63 --- /dev/null +++ b/packages/analytics/src/ui/content.ts @@ -0,0 +1,480 @@ +/** + * The per-entry page: every published entry with its 7- and 30-day views. + * + * This is the page the join exists for. Cloudflare's own dashboard lists + * paths that got traffic; this lists the site's entries, including the + * ones that got none, which is the list an editor can act on. + * + * Block Kit keeps no state between interactions, so the view (collection, + * sort, mode) travels in every control's `action_id`, and the position in + * the table's cursor. The table's sortable headers and its "Load more" + * send only `{ sort, cursor }`, which is why the rest has to be in the id. + * "Load more" replaces the table rather than appending to it: the response + * is a whole new page of blocks. + * + * Two modes: + * + * - **Each language** sorts server-side on the indexed `views7`/`views30` + * columns and pages with the storage cursor, so it scales with the site. + * Each row also carries its translations' 30-day total. + * - **Languages combined** shows one row per translation group. A group + * total is not stored, so it cannot be sorted in storage: the page reads + * up to `COMBINED_READ` entries in the chosen order, groups and sorts + * those, and says so when there were more. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { langOf, t, type Lang } from "../i18n.js"; +import { dailyStore, entriesStore, BIND_LIMIT, type Typed } from "../store/access.js"; +import type { EntryRow } from "../store/rows.js"; +import { indexOutdated, type SyncState } from "../sync/scheduler.js"; +import { addDays, daysBetween, utcDay, type Day } from "../sync/window.js"; +import { actions, button, context, empty, link, table, type AnalyticsBlock } from "./blocks.js"; +import { formatCount, formatDay } from "./format.js"; +import { PAGE_PATH } from "./page.js"; +import { statusLine } from "./status.js"; + +export const CONTENT_PATH = "/analytics/content"; +export const VIEW_ACTION = "analytics:content:view"; +export const TABLE_ACTION = "analytics:content:table"; +export const REBUILD_ACTION = "analytics:content:rebuild"; + +/** + * Rows per table page. A sandboxed response is capped at 2 000 nodes and + * 256 KiB, and a row here has up to eight cells. + */ +export const PAGE_ROWS = 50; + +/** Entries the combined mode reads: three storage pages. */ +export const COMBINED_READ = 3 * BIND_LIMIT; + +/** Pages of translations read to total the groups on one table page. */ +const MEMBER_PAGES = 2; + +export type SortKey = "views7" | "views30"; + +export interface ContentView { + /** A collection slug, or null for all routable collections. */ + collection: string | null; + sort: SortKey; + dir: "asc" | "desc"; + mode: "entries" | "combined"; + /** Rows before this table page, for the "Entries 51 to 100" line. */ + offset: number; + /** The storage cursor of this table page; combined mode pages in memory. */ + cursor?: string; +} + +export const DEFAULT_VIEW: ContentView = { collection: null, sort: "views30", dir: "desc", mode: "entries", offset: 0 }; + +export interface ContentRow { + title: string; + collection: string; + locale: string; + path: string; + views7: number; + views30: number; + /** 30-day views of every published translation, this entry included. */ + allLanguages?: number; + /** Combined mode: each language's views in the sort's window, "de 30 · en 12". */ + byLanguage?: string; + publishedAt?: string; +} + +export interface ContentInput { + view: ContentView; + state: SyncState; + collections: Array<{ slug: string; label: string }>; + rows: ContentRow[]; + /** The table's `next_cursor`, when there is another page. */ + nextCursor?: string; + /** True when the rows show more than one language. */ + multilingual: boolean; + /** The oldest stored day, when the store has any. */ + historySince?: Day; + /** Combined mode: how many entries were read when more existed. */ + partial?: number; + /** Where an index walk stands, while one runs. */ + indexing?: { at: number; total: number; rebuilding: boolean }; + now: Date; + locale?: string; +} + +/** + * The view an interaction asks for, and whether it asks for a rebuild. + * Anything unrecognised gets the default view rather than an error. + */ +export function parseContentInput(input: Record): { view: ContentView; rebuild: boolean } { + const id = typeof input.action_id === "string" ? input.action_id : ""; + if (input.type !== "block_action" || !id) return { view: DEFAULT_VIEW, rebuild: false }; + + const cut = id.indexOf("|"); + const base = cut < 0 ? id : id.slice(0, cut); + const view = decodeView(cut < 0 ? "" : id.slice(cut + 1)); + + if (base === REBUILD_ACTION) return { view, rebuild: true }; + if (base === VIEW_ACTION) return { view, rebuild: false }; + if (base !== TABLE_ACTION) return { view: DEFAULT_VIEW, rebuild: false }; + + const value = typeof input.value === "object" && input.value !== null ? (input.value as Record) : {}; + const sort = sortOf(value.sort); + const position = typeof value.cursor === "string" ? decodeCursor(value.cursor) : { offset: 0 }; + return { view: { ...view, ...(sort ?? {}), ...position }, rebuild: false }; +} + +export function viewAction(view: ContentView): string { + return `${VIEW_ACTION}|${encodeView(view)}`; +} + +/** + * Gather the page. Calls: the schema's collection list, the oldest stored + * day, then either one entries page and up to two pages of translations, + * or up to three entries pages for the combined mode. + */ +export async function loadContent( + ctx: PluginContext, + requested: ContentView, + state: SyncState, + now: Date, + locale?: string, +): Promise { + const routable = ctx.schema + ? (await ctx.schema.listCollections()) + .filter((c) => c.routable && !c.hidden) + .map((c) => ({ slug: c.slug, label: c.label || c.slug })) + .sort((a, b) => a.slug.localeCompare(b.slug)) + : []; + + // A collection that is gone, or never was routable, falls back to all. + const collection = + requested.collection && routable.some((c) => c.slug === requested.collection) ? requested.collection : null; + const view = { ...requested, collection }; + + const history = await dailyStore(ctx)?.query({ orderBy: { date: "asc" }, limit: 1 }); + const base = { + state, + collections: routable, + historySince: history?.items[0]?.data.date, + indexing: indexingOf(state, routable.map((c) => c.slug)), + now, + locale, + }; + + const entries = entriesStore(ctx); + if (!entries) return { ...base, view, rows: [], multilingual: false }; + + const where = collection ? { where: { collection } } : {}; + const siteLocale = ctx.site.locale; + + if (view.mode === "combined") { + const read: EntryRow[] = []; + let cursor: string | undefined; + let more = false; + for (let i = 0; i < COMBINED_READ / BIND_LIMIT; i++) { + const page = await entries.query({ + ...where, + orderBy: { [view.sort]: view.dir }, + limit: BIND_LIMIT, + ...(cursor ? { cursor } : {}), + }); + read.push(...page.items.map((item) => item.data)); + more = page.hasMore && Boolean(page.cursor); + if (!more) break; + cursor = page.cursor; + } + + const groups = combine(published(read), view, siteLocale, locale); + const rows = groups.slice(view.offset, view.offset + PAGE_ROWS); + const end = view.offset + rows.length; + return { + ...base, + view, + rows, + ...(end < groups.length && { nextCursor: encodeCursor(end) }), + multilingual: true, + ...(more && { partial: read.length }), + }; + } + + const page = await entries.query({ + ...where, + orderBy: { [view.sort]: view.dir }, + limit: PAGE_ROWS, + ...(view.cursor ? { cursor: view.cursor } : {}), + }); + const shown = published(page.items.map((item) => item.data)); + const members = await readMembers(entries, [...new Set(shown.map((row) => row.translationGroup))]); + + const rows = shown.map((row) => ({ + ...rowOf(row), + allLanguages: (members.get(row.translationGroup) ?? [row]).reduce((sum, m) => sum + m.views30, 0), + })); + // More than one language in sight, not "a locale other than the site's": + // `ctx.site.locale` is not the content's default language everywhere + // (on eisbachcode.de, whose default is de, it is not "de"). + const multilingual = + [...members.values()].some((group) => group.length > 1) || new Set(shown.map((row) => row.locale)).size > 1; + + return { + ...base, + view, + rows, + // Counted in rows shown, so "Entries 51 to 100" matches what the reader + // saw; unpublished rows the page skipped do not count. + ...(page.hasMore && page.cursor && { nextCursor: encodeCursor(view.offset + shown.length, page.cursor) }), + multilingual, + }; +} + +export function renderContent(input: ContentInput): AnalyticsBlock[] { + const { view, state, now } = input; + const lang = langOf(input.locale); + const out: AnalyticsBlock[] = [collectionButtons(input, lang), controls(input, lang)]; + + const notes = [ + t(lang, view.dir === "desc" ? "sortMostViewed" : "sortLeastViewed", { days: windowOf(view.sort) }), + ...(view.offset > 0 || input.nextCursor + ? [t(lang, "showingRows", { from: view.offset + 1, to: view.offset + input.rows.length })] + : []), + ...historyNote(input, lang), + ...(input.partial ? [t(lang, "combinedPartial", { count: input.partial })] : []), + ]; + out.push(context(notes.join(" "))); + + if (input.indexing) { + const { at, total, rebuilding } = input.indexing; + out.push(context(t(lang, rebuilding ? "rebuildingAt" : "indexingAt", { at, total }))); + } + const status = statusLine(state, now, lang, {}); + if (status) out.push(context(status)); + + if (input.rows.length === 0 && view.offset === 0 && view.collection === null) { + out.push( + empty({ + title: t(lang, "noEntriesYet"), + description: t(lang, state.indexComplete ? "noEntriesNoUrls" : "noEntriesIndexing"), + }), + ); + return out; + } + + out.push(entryTable(input, lang)); + return out; +} + +function collectionButtons(input: ContentInput, lang: Lang) { + const { view } = input; + // Buttons rather than a select: the host's select shows the raw value + // when closed. Sites rarely route more than a handful of collections, + // and the row wraps when they do. + const choices: Array<{ slug: string | null; label: string }> = [ + { slug: null, label: t(lang, "allCollections") }, + ...input.collections, + ]; + return actions( + choices.map((choice) => + button(viewAction({ ...view, collection: choice.slug, offset: 0, cursor: undefined }), choice.label, { + style: choice.slug === view.collection ? "primary" : "secondary", + }), + ), + { blockId: "analytics:content:collections" }, + ); +} + +function controls(input: ContentInput, lang: Lang) { + const { view } = input; + const start = { ...view, offset: 0, cursor: undefined }; + const elements = [ + ...(input.multilingual || view.mode === "combined" + ? (["entries", "combined"] as const).map((mode) => + button(viewAction({ ...start, mode }), t(lang, mode === "entries" ? "modeEntries" : "modeCombined"), { + style: mode === view.mode ? "primary" : "secondary", + }), + ) + : []), + ...(view.offset > 0 ? [button(viewAction(start), t(lang, "firstPage"), { style: "secondary" })] : []), + button(`${REBUILD_ACTION}|${encodeView(view)}`, t(lang, "rebuildIndex"), { style: "secondary" }), + link(t(lang, "overview"), { kind: "plugin-page", path: PAGE_PATH }, { appearance: "secondary" }), + ]; + return actions(elements, { blockId: "analytics:content:controls" }); +} + +function entryTable(input: ContentInput, lang: Lang) { + const { view } = input; + const labels = new Map(input.collections.map((c) => [c.slug, c.label])); + const combined = view.mode === "combined"; + const showCollection = view.collection === null; + const showLanguage = input.multilingual && !combined; + + return table({ + blockId: "analytics:content:entries", + // The view rides in the id: sorting and "Load more" send only + // `{ sort, cursor }`. + pageActionId: `${TABLE_ACTION}|${encodeView(view)}`, + ...(input.nextCursor !== undefined && { nextCursor: input.nextCursor }), + columns: [ + { key: "entry", label: t(lang, "colEntry"), format: "text" }, + ...(showLanguage ? [{ key: "language", label: t(lang, "colLanguage"), format: "badge" as const }] : []), + ...(showCollection ? [{ key: "collection", label: t(lang, "colCollection"), format: "badge" as const }] : []), + { key: "path", label: t(lang, "colPath"), format: "code" }, + ...(combined + ? [{ key: "byLanguage", label: t(lang, "colByLanguage", { days: windowOf(view.sort) }), format: "text" as const }] + : []), + { key: "views7", label: t(lang, "col7Days"), format: "number", sortable: true }, + { key: "views30", label: t(lang, "col30Days"), format: "number", sortable: true }, + ...(showLanguage ? [{ key: "allLanguages", label: t(lang, "colAllLanguages"), format: "number" as const }] : []), + { key: "published", label: t(lang, "colPublished"), format: "text" }, + ], + rows: input.rows.map((row) => ({ + entry: row.title || "—", + language: row.locale, + collection: labels.get(row.collection) ?? row.collection, + path: row.path, + byLanguage: row.byLanguage ?? "", + views7: row.views7, + views30: row.views30, + allLanguages: row.allLanguages ?? row.views30, + published: row.publishedAt ? formatDay(row.publishedAt.slice(0, 10), lang) : "", + })), + emptyText: t(lang, "noEntriesHere"), + }); +} + +/** + * One row per translation group, sorted by the group's total in the + * view's window. The row is named after the group's original, the entry + * the others were translated from: its id is the group id. Without it in + * the rows read, the site-locale version, then the most viewed. + */ +export function combine(rows: EntryRow[], view: ContentView, siteLocale: string, readerLocale?: string): ContentRow[] { + const groups = new Map(); + for (const row of rows) groups.set(row.translationGroup, [...(groups.get(row.translationGroup) ?? []), row]); + + const out: ContentRow[] = []; + for (const members of groups.values()) { + const ranked = [...members].sort((a, b) => b[view.sort] - a[view.sort] || a.locale.localeCompare(b.locale)); + const lead = + members.find((m) => m.entryId === m.translationGroup) ?? + members.find((m) => m.locale === siteLocale) ?? + ranked[0]!; + out.push({ + ...rowOf(lead), + views7: sum(members, "views7"), + views30: sum(members, "views30"), + byLanguage: ranked.map((m) => `${m.locale} ${formatCount(m[view.sort], readerLocale)}`).join(" · "), + }); + } + + const sign = view.dir === "desc" ? -1 : 1; + return out.sort((a, b) => sign * (a[view.sort] - b[view.sort]) || a.path.localeCompare(b.path)); +} + +function rowOf(row: EntryRow): ContentRow { + return { + title: row.title, + collection: row.collection, + locale: row.locale, + path: row.path, + views7: row.views7, + views30: row.views30, + ...(row.publishedAt && { publishedAt: row.publishedAt }), + }; +} + +/** Rows kept for history after an unpublish or delete are not on the site. */ +function published(rows: EntryRow[]): EntryRow[] { + return rows.filter((row) => row.status === "published"); +} + +/** Every published translation of the given groups, by group. */ +async function readMembers(entries: Typed, groups: string[]): Promise> { + const out = new Map(); + if (groups.length === 0) return out; + let cursor: string | undefined; + for (let i = 0; i < MEMBER_PAGES; i++) { + const page = await entries.query({ + where: { translationGroup: { in: groups } }, + limit: BIND_LIMIT, + ...(cursor ? { cursor } : {}), + }); + for (const { data } of page.items) { + if (data.status !== "published") continue; + out.set(data.translationGroup, [...(out.get(data.translationGroup) ?? []), data]); + } + if (!page.hasMore || !page.cursor) break; + cursor = page.cursor; + } + return out; +} + +function historyNote(input: ContentInput, lang: Lang): string[] { + if (!input.historySince) return []; + const thirtyStart = addDays(utcDay(input.now), -29); + if (daysBetween(thirtyStart, input.historySince) <= 0) return []; + return [t(lang, "historyThirty", { date: formatDay(input.historySince, lang) })]; +} + +/** Where a running index walk stands, or undefined when none runs. */ +function indexingOf(state: SyncState, slugs: string[]): ContentInput["indexing"] { + const rebuilding = Boolean(state.indexComplete) && indexOutdated(state); + if (state.indexComplete && !rebuilding) return undefined; + const total = slugs.length; + if (total === 0) return undefined; + const current = state.index?.collection; + const at = current ? slugs.filter((slug) => slug <= current).length : 1; + return { at: Math.max(1, Math.min(at, total)), total, rebuilding }; +} + +function sum(rows: EntryRow[], key: SortKey): number { + return rows.reduce((total, row) => total + row[key], 0); +} + +function windowOf(sort: SortKey): number { + return sort === "views7" ? 7 : 30; +} + +function sortOf(value: unknown): Pick | null { + if (typeof value !== "object" || value === null) return null; + const { key, dir } = value as Record; + if (key !== "views7" && key !== "views30") return null; + return { sort: key, dir: dir === "asc" ? "asc" : "desc" }; +} + +/** The view minus its position, as it travels in an action id. */ +function encodeView(view: ContentView): string { + return [view.collection ?? "", view.sort, view.dir, view.mode].map(encodeURIComponent).join("|"); +} + +function decodeView(encoded: string): ContentView { + const [collection, sort, dir, mode] = encoded.split("|").map(decodeOnce); + return { + collection: collection && /^[a-z][a-z0-9_]*$/.test(collection) ? collection : null, + sort: sort === "views7" ? "views7" : "views30", + dir: dir === "asc" ? "asc" : "desc", + mode: mode === "combined" ? "combined" : "entries", + offset: 0, + }; +} + +function encodeCursor(offset: number, cursor?: string): string { + return `${offset}~${cursor ?? ""}`; +} + +function decodeCursor(value: string): { offset: number; cursor?: string } { + const cut = value.indexOf("~"); + const offset = Number(cut < 0 ? value : value.slice(0, cut)); + const cursor = cut < 0 ? "" : value.slice(cut + 1); + return { + offset: Number.isInteger(offset) && offset > 0 ? offset : 0, + ...(cursor && { cursor }), + }; +} + +function decodeOnce(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return ""; + } +} diff --git a/packages/analytics/src/ui/format.ts b/packages/analytics/src/ui/format.ts new file mode 100644 index 0000000..107d789 --- /dev/null +++ b/packages/analytics/src/ui/format.ts @@ -0,0 +1,115 @@ +/** + * Formatting for the admin UI. Pure. + * + * The locale is the catalogue language from `langOf()`, not the raw admin + * locale: text comes from the plugin's own catalogue, and numbers, dates + * and relative times use the same language so they never mix with it. A + * German editor sees "1.234" and "vor 3 Minuten". + */ + +import { langOf, t } from "../i18n.js"; + +/** Digits grouped the way the reader's locale groups them. */ +export function formatCount(value: number, locale?: string): string { + if (!Number.isFinite(value)) return "0"; + try { + return new Intl.NumberFormat(locale ?? "en").format(value); + } catch { + return String(Math.trunc(value)); + } +} + +/** + * "3 minutes ago", in the reader's language. + * + * Returns `null` for a missing timestamp rather than a placeholder, so the + * caller decides what absent looks like. + */ +export function formatAge(iso: string | undefined, now: Date, locale?: string): string | null { + if (!iso) return null; + const then = Date.parse(iso); + if (Number.isNaN(then)) return null; + + const seconds = Math.round((then - now.getTime()) / 1000); + const [value, unit] = pickUnit(seconds); + + try { + return new Intl.RelativeTimeFormat(locale ?? "en", { numeric: "auto" }).format(value, unit); + } catch { + const abs = Math.abs(value); + return value <= 0 ? `${abs} ${unit}${abs === 1 ? "" : "s"} ago` : `in ${abs} ${unit}${abs === 1 ? "" : "s"}`; + } +} + +function pickUnit(seconds: number): [number, Intl.RelativeTimeFormatUnit] { + const abs = Math.abs(seconds); + if (abs < 60) return [seconds, "second"]; + if (abs < 3600) return [Math.round(seconds / 60), "minute"]; + if (abs < 86_400) return [Math.round(seconds / 3600), "hour"]; + return [Math.round(seconds / 86_400), "day"]; +} + +export type Trend = "up" | "down" | "neutral"; + +/** + * Which way the arrow points. + * + * `null` when there is no comparable previous period — on a fresh install + * there genuinely is no history, and a "neutral" arrow would claim + * "unchanged", which is a different and false statement. + */ +export function trendOf(current: number, previous: number | null): Trend | null { + if (previous === null) return null; + if (current > previous) return "up"; + if (current < previous) return "down"; + return "neutral"; +} + +/** + * The comparison sentence under a stat. + * + * Growth from zero has no percentage — the honest phrasing is "up from + * none", not "+Infinity%". + */ +export function comparisonText(current: number, previous: number | null, locale?: string): string { + const lang = langOf(locale); + if (previous === null) return t(lang, "noEarlierPeriod"); + if (previous === 0 && current === 0) return t(lang, "noneEitherPeriod"); + if (previous === 0) return t(lang, "upFromNone"); + const ratio = (current - previous) / previous; + const change = new Intl.NumberFormat(lang, { + style: "percent", + signDisplay: "exceptZero", + maximumFractionDigits: Math.abs(ratio) < 0.01 ? 1 : 0, + }).format(ratio); + return t(lang, "vsPrevious", { change }); +} + +/** A UTC day, written the reader's way: "18 Sept 2026", "18.09.2026". */ +export function formatDay(day: string, locale?: string): string { + const ms = Date.parse(`${day}T00:00:00.000Z`); + if (Number.isNaN(ms)) return day; + try { + return new Intl.DateTimeFormat(langOf(locale), { dateStyle: "medium", timeZone: "UTC" }).format(ms); + } catch { + return day; + } +} + +/** + * Suffix marking numbers the provider estimated rather than counted. + * + * Silence here is what makes an editor compare the widget with the + * provider's dashboard and trust neither. Named only for Cloudflare, whose + * sampling is the reason; demo data merely imitates it. + */ +export function qualifier( + opts: { estimated?: boolean; provisional?: boolean; provider?: string }, + locale?: string, +): string { + const lang = langOf(locale); + const notes: string[] = []; + if (opts.estimated) notes.push(t(lang, opts.provider === "demo" ? "estimatedSampled" : "estimatedCloudflare")); + if (opts.provisional) notes.push(t(lang, "todayCounting")); + return notes.join(" · "); +} diff --git a/packages/analytics/src/ui/page.ts b/packages/analytics/src/ui/page.ts new file mode 100644 index 0000000..58a6ec6 --- /dev/null +++ b/packages/analytics/src/ui/page.ts @@ -0,0 +1,454 @@ +/** + * The analytics page: the widget's numbers with room to breathe. + * + * Unlike the widget, the page asks the provider live, once per load: it is + * opened on purpose, and a fresh install would otherwise show nothing + * until the sync has caught up, which takes hours to days. The live read + * never reaches past the provider's exact window: measured on + * eisbachcode.de, a sampled 30-day query reported 300 page views on one + * day for a week that had 25 over five. Older days come from the store + * only, so a young install shows a shorter history rather than a wrong + * one. Block Kit keeps no state between interactions, so the chosen range + * travels in the range buttons' and the refresh button's `value`. + * + * When the live read fails, the page falls back to the store alone, and + * two limits shape what it can honestly show: + * + * - Per-entry numbers live in `daily`, one row per path and day, written + * only from the day the plugin started syncing that entry. The entry + * table therefore says which days it covers instead of implying the full + * range. + * - Referrers and countries are a snapshot of the latest overview window, + * not a per-day store, so they are labelled with the day they start. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { entriesStore, dailyStore, rollupStore, BIND_LIMIT } from "../store/access.js"; +import type { LabelledRow, Overview, Provider } from "../providers/types.js"; +import { historyReaches, sumWindow, type EntryRow, type RollupRow } from "../store/rows.js"; +import type { SyncState } from "../sync/scheduler.js"; +import { addDays, daysBetween, utcDay, type Day } from "../sync/window.js"; +import { + actions, + button, + columns, + context, + empty, + header, + link, + stats, + table, + timeseries, + type AnalyticsBlock, +} from "./blocks.js"; +import { langOf, t, type Lang } from "../i18n.js"; +import { comparisonText, formatCount, formatDay, trendOf } from "./format.js"; +import { emptyReason, statusLine } from "./status.js"; + +export const PAGE_PATH = "/analytics"; +/** The per-entry page (`./content.ts`), named here to keep the import one-way. */ +const CONTENT_PAGE_PATH = "/analytics/content"; +export const RANGE_ACTION = "analytics:range"; +export const PAGE_REFRESH_ACTION = "analytics:page:refresh"; +/** Opens the setup check (`./setup.ts`), carrying the range to return to. */ +export const SETUP_ACTION = "analytics:setup"; + +const RANGES = [7, 30, 90] as const; +export type RangeDays = (typeof RANGES)[number]; +export const DEFAULT_RANGE: RangeDays = 30; + +const TOP_ENTRIES = 20; +/** Rollup pages per render: the range plus the previous period, at most 180 days. */ +const ROLLUP_PAGES = 2; +/** + * Daily pages per render. Each is one bridge call in the sandbox, which + * allows ten per invocation; three leave room for the rest of the load. + */ +const DAILY_PAGES = 3; + +export function parseRange(value: unknown): RangeDays { + const n = typeof value === "string" ? Number(value) : value; + return (RANGES as readonly unknown[]).includes(n) ? (n as RangeDays) : DEFAULT_RANGE; +} + +export interface EntryTotals { + path: string; + pageviews: number; + visits: number; +} + +export interface TopEntries { + rows: EntryTotals[]; + /** Oldest day the totals include; absent when nothing was read. */ + coveredSince?: Day; + /** True when the read budget ran out before the start of the range. */ + partial: boolean; +} + +export interface PageInput { + state: SyncState; + range: RangeDays; + /** Rollups for the range and the equal period before it. */ + rollups: RollupRow[]; + topEntries: TopEntries; + entriesByPath: Map; + /** The provider's own dashboard, or null when it has none (demo data). */ + dashboardUrl: string | null; + /** + * Referrers and countries from the live answer, and the first day it + * covers, which is later than the range start for long ranges. + */ + breakdowns?: { referrers: LabelledRow[]; countries: LabelledRow[]; since: Day }; + now: Date; + locale?: string; +} + +/** + * Gather the page. Calls: the live read (one fetch), up to two rollup + * pages, one entries lookup; without a live answer, up to three daily + * pages as well. + */ +export async function loadPage( + ctx: PluginContext, + state: SyncState, + range: RangeDays, + now: Date, + dashboardUrl: string | null, + locale?: string, + provider?: Provider | null, +): Promise { + const today = utcDay(now); + + const rangeStart = addDays(today, -(range - 1)); + // One day short of the exact window: measured on eisbachcode.de, + // Cloudflare answers a query reaching back to today - 7 in about 1.1 s + // and one starting at today - 6 in about 0.1 s. The day in between is + // in the store from the first sync. + const exactStart = provider ? addDays(today, -(provider.capabilities.exactWindowDays - 1)) : rangeStart; + const liveSince = daysBetween(rangeStart, exactStart) > 0 ? exactStart : rangeStart; + const live = provider ? await provider.overview({ since: liveSince, until: today }) : null; + + const stored: RollupRow[] = []; + const rollup = rollupStore(ctx); + let rollupCursor: string | undefined; + for (let i = 0; rollup && i < ROLLUP_PAGES; i++) { + const page = await rollup.query({ + where: { date: { gte: addDays(today, -(range * 2 - 1)) } }, + orderBy: { date: "asc" }, + limit: BIND_LIMIT, + ...(rollupCursor ? { cursor: rollupCursor } : {}), + }); + stored.push(...page.items.map((item) => item.data)); + if (!page.hasMore || !page.cursor) break; + rollupCursor = page.cursor; + } + + const entries = entriesStore(ctx); + const lookup = async (top: TopEntries) => { + const paths = top.rows.map((row) => row.path); + return paths.length > 0 && entries ? await entries.getMany(paths) : new Map(); + }; + + if (live?.ok) { + const topEntries: TopEntries = { + rows: live.value.topPaths + .slice(0, TOP_ENTRIES) + .map((p) => ({ path: p.path, pageviews: p.pageviews, visits: p.visits })), + ...(liveSince !== rangeStart && { coveredSince: liveSince }), + partial: liveSince !== rangeStart, + }; + return { + state, + range, + rollups: mergeDays(stored, live.value, now), + topEntries, + entriesByPath: await lookup(topEntries), + dashboardUrl, + breakdowns: { referrers: live.value.referrers, countries: live.value.countries, since: liveSince }, + now, + locale, + }; + } + + const dailyRows: Array<{ date: Day; path: string; pageviews: number; visits: number }> = []; + const daily = dailyStore(ctx); + let dailyCursor: string | undefined; + let hasMore = false; + for (let i = 0; daily && i < DAILY_PAGES; i++) { + const page = await daily.query({ + where: { date: { gte: addDays(today, -(range - 1)) } }, + orderBy: { date: "desc" }, + limit: BIND_LIMIT, + ...(dailyCursor ? { cursor: dailyCursor } : {}), + }); + dailyRows.push(...page.items.map((item) => item.data)); + hasMore = page.hasMore && Boolean(page.cursor); + if (!hasMore) break; + dailyCursor = page.cursor; + } + + const topEntries = summarizeDaily(dailyRows, hasMore); + return { + state, + range, + rollups: stored, + topEntries, + entriesByPath: await lookup(topEntries), + dashboardUrl, + now, + locale, + }; +} + +/** + * One row per day: the live answer where it has the day, being the newer + * of the two, and the store for every day before the live window. + */ +export function mergeDays(stored: RollupRow[], live: Overview, now: Date): RollupRow[] { + const byDay = new Map(); + for (const row of live.series) { + byDay.set(row.date, { ...row, fetchedAt: now.toISOString() }); + } + for (const row of stored) { + if (!byDay.has(row.date)) byDay.set(row.date, row); + } + return [...byDay.values()]; +} + +/** + * Per-path totals from daily rows read newest first. + * + * When the read stopped with more rows left, the oldest day in hand may be + * missing some paths, so it is dropped rather than undercounted. + */ +export function summarizeDaily( + rows: Array<{ date: Day; path: string; pageviews: number; visits: number }>, + hasMore: boolean, +): TopEntries { + let usable = rows; + if (hasMore && rows.length > 0) { + const oldest = rows.reduce((min, row) => (daysBetween(row.date, min) > 0 ? row.date : min), rows[0]!.date); + usable = rows.filter((row) => row.date !== oldest); + } + + const byPath = new Map(); + let coveredSince: Day | undefined; + for (const row of usable) { + const acc = byPath.get(row.path) ?? { path: row.path, pageviews: 0, visits: 0 }; + acc.pageviews += row.pageviews; + acc.visits += row.visits; + byPath.set(row.path, acc); + if (coveredSince === undefined || daysBetween(row.date, coveredSince) > 0) coveredSince = row.date; + } + + const sorted = [...byPath.values()] + .sort((a, b) => b.pageviews - a.pageviews || a.path.localeCompare(b.path)) + .slice(0, TOP_ENTRIES); + return { rows: sorted, ...(coveredSince !== undefined && { coveredSince }), partial: hasMore }; +} + +export function renderPage(input: PageInput): AnalyticsBlock[] { + const { state, range, rollups, now, locale } = input; + const lang = langOf(locale); + const today = utcDay(now); + + if (rollups.length === 0) { + return [ + empty({ + title: t(lang, "noAnalyticsYet"), + description: emptyReason(state, now, lang), + actions: [refreshButton(range, lang), setupButton(range, lang)], + }), + ]; + } + + const inRange = rollups.filter((r) => within(r.date, addDays(today, -(range - 1)), today)); + const before = rollups.filter((r) => within(r.date, addDays(today, -(range * 2 - 1)), addDays(today, -range))); + const current = sumWindow(inRange, today); + const previous = historyReaches(rollups, addDays(today, -(range * 2 - 1))) ? sumWindow(before, today) : null; + + const out: AnalyticsBlock[] = [controls(input, lang)]; + + const visitsTrend = trendOf(current.visits, previous?.visits ?? null); + const viewsTrend = trendOf(current.pageviews, previous?.pageviews ?? null); + out.push( + stats([ + { + label: t(lang, "visitsLastDays", { days: range }), + value: formatCount(current.visits, lang), + description: comparisonText(current.visits, previous?.visits ?? null, lang), + ...(visitsTrend ? { trend: visitsTrend } : {}), + }, + { + label: t(lang, "pageviewsLastDays", { days: range }), + value: formatCount(current.pageviews, lang), + description: comparisonText(current.pageviews, previous?.pageviews ?? null, lang), + ...(viewsTrend ? { trend: viewsTrend } : {}), + }, + ]), + ); + + const days = [...inRange].sort((a, b) => daysBetween(b.date, a.date)); + const at = (day: Day) => Date.parse(`${day}T00:00:00.000Z`); + out.push( + timeseries( + [ + { name: t(lang, "pageviews"), data: days.map((d) => [at(d.date), d.pageviews] as [number, number]) }, + { name: t(lang, "visits"), data: days.map((d) => [at(d.date), d.visits] as [number, number]) }, + ], + { blockId: "analytics:chart", height: 300, gradient: true }, + ), + ); + + const history = rollups.reduce( + (min, r) => (min === undefined || daysBetween(r.date, min) > 0 ? r.date : min), + undefined, + ); + const startsLate = history !== undefined && daysBetween(addDays(today, -(range - 1)), history) > 0; + const note = statusLine(state, now, lang, { + estimated: current.estimated, + provisional: current.provisional, + unmatched: true, + }); + const notes = [note, startsLate && history ? t(lang, "historyStarts", { date: formatDay(history, lang) }) : null] + .filter(Boolean) + .join(" · "); + if (notes) out.push(context(notes)); + + out.push(header(t(lang, "topEntries"))); + out.push(context(coverageText(input.topEntries, range, lang, Boolean(input.breakdowns)))); + out.push( + table({ + blockId: "analytics:entries", + pageActionId: "analytics:entries:page", + columns: [ + { key: "entry", label: t(lang, "colEntry"), format: "text" }, + // Text, not a badge: paths that are not entries have no + // collection, and an empty badge renders as a black pill. + { key: "collection", label: t(lang, "colCollection"), format: "text" }, + { key: "path", label: t(lang, "colPath"), format: "code" }, + { key: "views", label: t(lang, "colViews"), format: "number" }, + { key: "visits", label: t(lang, "colVisits"), format: "number" }, + ], + rows: input.topEntries.rows.map((row) => { + const known = input.entriesByPath.get(row.path); + return { + entry: known?.title || "—", + collection: known ? (state.collectionLabels?.[known.collection] ?? known.collection) : "", + path: row.path, + views: row.pageviews, + visits: row.visits, + }; + }), + emptyText: t(lang, "entriesEmpty"), + }), + ); + + out.push( + columns([ + [ + header(t(lang, "referrers")), + labelledTable( + "analytics:referrers", + t(lang, "colSource"), + input.breakdowns?.referrers ?? state.referrers ?? [], + (l) => l, + lang, + ), + ], + [ + header(t(lang, "countries")), + labelledTable( + "analytics:countries", + t(lang, "colCountry"), + input.breakdowns?.countries ?? state.countries ?? [], + countryName(lang), + lang, + ), + ], + ]), + ); + if (input.breakdowns && input.breakdowns.since !== addDays(today, -(range - 1))) { + out.push(context(t(lang, "breakdownsSince", { date: formatDay(input.breakdowns.since, lang) }))); + } else if (!input.breakdowns && state.snapshotSince) { + out.push(context(t(lang, "snapshotSince", { date: formatDay(state.snapshotSince, lang) }))); + } + + return out; +} + +function controls(input: PageInput, lang: Lang) { + // Buttons, not a select: the host's select shows the raw value when + // closed, and its label sets it out of line with the buttons beside it. + const elements = [ + ...RANGES.map((days) => + button(RANGE_ACTION, t(lang, "rangeDays", { count: days }), { + style: days === input.range ? "primary" : "secondary", + value: days, + }), + ), + refreshButton(input.range, lang), + link(t(lang, "perEntry"), { kind: "plugin-page", path: CONTENT_PAGE_PATH }, { appearance: "secondary" }), + ...(input.dashboardUrl + ? [link(t(lang, "openInCloudflare"), { kind: "external", url: input.dashboardUrl }, { appearance: "secondary" })] + : []), + setupButton(input.range, lang), + ]; + return actions(elements, { blockId: "analytics:controls" }); +} + +function refreshButton(range: RangeDays, lang: Lang) { + return button(PAGE_REFRESH_ACTION, t(lang, "refresh"), { style: "secondary", value: range }); +} + +function setupButton(range: RangeDays, lang: Lang) { + return button(SETUP_ACTION, t(lang, "checkSetup"), { style: "secondary", value: range }); +} + +function labelledTable( + blockId: string, + label: string, + rows: Array<{ label: string; visits: number }>, + display: (label: string) => string, + lang: Lang, +) { + return table({ + blockId, + pageActionId: `${blockId}:page`, + columns: [ + { key: "label", label, format: "text" }, + { key: "visits", label: t(lang, "colVisits"), format: "number" }, + ], + rows: rows.map((row) => ({ label: display(row.label), visits: row.visits })), + emptyText: t(lang, "nothingRecorded"), + }); +} + +/** ISO-2 codes as names in the reader's language, the code where that fails. */ +function countryName(locale: string | undefined): (code: string) => string { + let names: Intl.DisplayNames | null = null; + try { + names = new Intl.DisplayNames([locale ?? "en"], { type: "region" }); + } catch { + names = null; + } + return (code) => { + try { + return names?.of(code) ?? code; + } catch { + return code; + } + }; +} + +function coverageText(top: TopEntries, range: RangeDays, lang: Lang, live: boolean): string { + if (!top.coveredSince) return t(lang, "coverageRange", { days: range }); + const date = formatDay(top.coveredSince, lang); + if (live) return t(lang, "coverageLive", { date }); + return t(lang, top.partial ? "coveragePartial" : "coverageMatched", { date }); +} + +function within(day: Day, since: Day, until: Day): boolean { + return daysBetween(since, day) >= 0 && daysBetween(day, until) >= 0; +} diff --git a/packages/analytics/src/ui/panel.ts b/packages/analytics/src/ui/panel.ts new file mode 100644 index 0000000..605ff7e --- /dev/null +++ b/packages/analytics/src/ui/panel.ts @@ -0,0 +1,113 @@ +/** + * The entry editor's Views panel: this entry's 7- and 30-day views, and + * its translations'. + * + * It puts the numbers where editors work, which the dashboard and the + * analytics pages cannot. The host loads it only when opened and lets in + * only people who may edit the entry, so authors see their own entries' + * numbers without the `plugins:read` the analytics pages need. + * + * Storage only, like the per-entry page: two indexed queries, by entry id + * and by translation group. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { langOf, t } from "../i18n.js"; +import { entriesStore, BIND_LIMIT } from "../store/access.js"; +import type { EntryRow } from "../store/rows.js"; +import type { SyncState } from "../sync/scheduler.js"; +import { actions, context, empty, link, stats, table, type AnalyticsBlock } from "./blocks.js"; +import { CONTENT_PATH } from "./content.js"; +import { formatCount } from "./format.js"; +import { statusLine } from "./status.js"; + +export const PANEL_ID = "views"; + +export interface PanelInput { + /** The entry's row at the path it is published under, or its latest row. */ + current: EntryRow | null; + /** Published translations in the entry's group, the entry included. */ + members: EntryRow[]; + state: SyncState; + now: Date; + locale?: string; +} + +export async function loadPanel( + ctx: PluginContext, + entryId: string | null, + state: SyncState, + now: Date, + locale?: string, +): Promise { + const entries = entriesStore(ctx); + const base = { state, now, locale }; + if (!entries || !entryId) return { ...base, current: null, members: [] }; + + const mine = (await entries.query({ where: { entryId }, limit: BIND_LIMIT })).items.map((item) => item.data); + const current = mine.find((row) => row.status === "published") ?? latest(mine); + if (!current) return { ...base, current: null, members: [] }; + + const group = await entries.query({ where: { translationGroup: current.translationGroup }, limit: BIND_LIMIT }); + const members = group.items.map((item) => item.data).filter((row) => row.status === "published"); + return { ...base, current, members: members.length > 0 ? members : [current] }; +} + +export function renderPanel(input: PanelInput): AnalyticsBlock[] { + const { current, members, state, now } = input; + const lang = langOf(input.locale); + const more = actions([link(t(lang, "perEntry"), { kind: "plugin-page", path: CONTENT_PATH }, { appearance: "secondary" })]); + + if (!current) { + return [empty({ title: t(lang, "panelNoPage"), description: t(lang, "panelNoPageDetail") }), more]; + } + + const out: AnalyticsBlock[] = []; + const translated = members.length > 1; + // Two cards, not three: the editor sidebar is narrow, a third card is + // clipped, and the host's drag handle sits on the last one. The + // translations' total goes in the context line instead. + out.push( + stats([ + { label: t(lang, "pageviewsLastDays", { days: 7 }), value: formatCount(current.views7, lang) }, + { label: t(lang, "pageviewsLastDays", { days: 30 }), value: formatCount(current.views30, lang) }, + ]), + ); + + if (translated) { + out.push( + table({ + blockId: "analytics:panel:languages", + pageActionId: "analytics:panel:languages:page", + columns: [ + { key: "language", label: t(lang, "colLanguage"), format: "badge" }, + { key: "path", label: t(lang, "colPath"), format: "code" }, + { key: "views7", label: t(lang, "col7Days"), format: "number" }, + { key: "views30", label: t(lang, "col30Days"), format: "number" }, + ], + rows: [...members] + .sort((a, b) => b.views30 - a.views30 || a.locale.localeCompare(b.locale)) + .map((row) => ({ language: row.locale, path: row.path, views7: row.views7, views30: row.views30 })), + }), + ); + } + + const notes = [ + ...(translated ? [t(lang, "panelAllLanguages", { count: formatCount(sum(members), lang) })] : []), + current.status === "published" ? t(lang, "panelCountedAt", { path: current.path }) : t(lang, "panelNotPublished"), + statusLine(state, now, lang, {}), + ].filter(Boolean); + out.push(context(notes.join(" · "))); + out.push(more); + return out; +} + +function sum(rows: EntryRow[]): number { + return rows.reduce((total, row) => total + row.views30, 0); +} + +/** The most recently written row: where the entry was last published. */ +function latest(rows: EntryRow[]): EntryRow | undefined { + return [...rows].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0]; +} diff --git a/packages/analytics/src/ui/setup.ts b/packages/analytics/src/ui/setup.ts new file mode 100644 index 0000000..a112d97 --- /dev/null +++ b/packages/analytics/src/ui/setup.ts @@ -0,0 +1,430 @@ +/** + * The setup check: a view of the analytics page that runs every check the + * numbers depend on and names the fix for each one that fails. + * + * The generated settings form stays the only place to enter values; this + * view only reads. It exists because most setup mistakes do not fail, they + * produce zeroes: a site tag that is really the beacon token, a hostname + * filter that excludes the host Cloudflare reports, a site without a + * stored URL, a Worker without a Cron Trigger. None of those raises an + * error the sync could record. + * + * Calls: the state, the settings, the task list, the waiting mark until the + * first sync, and one discovery request to Cloudflare, which answers + * access, site tag, the site list and the hostnames at once. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { langOf, problemText, t, type Lang, type MessageKey, type Problem } from "../i18n.js"; +import { normalizeHost } from "../index/paths.js"; +import type { Result, Site } from "../providers/types.js"; +import { readSettings, type AnalyticsSettings } from "../settings.js"; +import { buildProvider, readState, REFRESH_TASK, SYNC_TASK, WAITING_KEY, type SyncState } from "../sync/scheduler.js"; +import { addDays, utcDay, UNSAMPLED_WINDOW_DAYS } from "../sync/window.js"; +import { actions, banner, button, code, context, header, table, type AnalyticsBlock } from "./blocks.js"; +import { formatAge, formatCount } from "./format.js"; +import { RANGE_ACTION, SETUP_ACTION } from "./page.js"; + +/** + * Discovery reads the exact window only. Over 30 days Cloudflare answers + * from a sample, and a host with little traffic drops out of it. + */ +const DISCOVERY_DAYS = UNSAMPLED_WINDOW_DAYS; + +/** How late a requested one-shot sync may run before the scheduler counts as stopped. */ +const ONESHOT_GRACE_MS = 10 * 60_000; + +const SITES_SHOWN = 10; + +export type CheckId = + | "source" + | "credentials" + | "access" + | "siteTag" + | "hosts" + | "siteUrl" + | "index" + | "scheduler" + | "lastSync"; + +export type CheckStatus = "ok" | "problem" | "waiting" | "skipped"; + +export interface Check { + id: CheckId; + status: CheckStatus; + detail: string; +} + +export interface TaskFacts { + name: string; + schedule: string; + nextRunAt: string; + lastRunAt: string | null; +} + +export interface SetupFacts { + provider: AnalyticsSettings["provider"]; + /** Settings keys the Cloudflare source still needs. */ + missing: string[]; + siteTag: string; + /** The hostnames the plugin counts; empty means every host the tag reports. */ + hosts: string[]; + siteUrl: string; + /** Null when nothing was asked: demo data, or credentials missing. */ + discovery: Result | null; + /** Null when the host runs no scheduler at all. */ + tasks: TaskFacts[] | null; + /** When the dashboard first found the sync waiting; read only while nothing has synced. */ + waitingSince?: string; + state: SyncState; + now: Date; +} + +export async function loadSetup(ctx: PluginContext, now: Date): Promise { + const state = await readState(ctx); + const settings = await readSettings(ctx); + const values = settings.ok ? settings.settings : settings.partial; + const tasks = ctx.cron ? await ctx.cron.list() : null; + const waitingSince = state.lastSync ? null : await ctx.kv.get(WAITING_KEY); + + let discovery: Result | null = null; + if (settings.ok && settings.settings.provider === "cloudflare") { + const provider = buildProvider(ctx, settings.settings); + const today = utcDay(now); + discovery = provider + ? await provider.discoverSites({ since: addDays(today, -(DISCOVERY_DAYS - 1)), until: today }) + : null; + } + + return { + provider: values.provider ?? "cloudflare", + missing: settings.ok ? [] : settings.missing, + siteTag: values.siteTag ?? "", + hosts: values.hosts ?? [], + siteUrl: ctx.site.url, + discovery, + ...(waitingSince && { waitingSince }), + tasks: + tasks?.map((task) => ({ + name: task.name, + schedule: task.schedule, + nextRunAt: task.nextRunAt, + lastRunAt: task.lastRunAt, + })) ?? null, + state, + now, + }; +} + +/** Every check, in the order a fix has to happen. */ +export function checkSetup(facts: SetupFacts, locale: string | undefined): Check[] { + const lang = langOf(locale); + const cloudflare = facts.provider === "cloudflare"; + const checks: Check[] = [ + { id: "source", status: "ok", detail: t(lang, cloudflare ? "sourceCloudflare" : "sourceDemo") }, + ]; + + if (cloudflare) checks.push(...cloudflareChecks(facts, lang)); + + checks.push( + siteUrlCheck(facts, lang), + indexCheck(facts, lang), + schedulerCheck(facts, lang), + lastSyncCheck(facts, lang), + ); + return checks; +} + +function cloudflareChecks(facts: SetupFacts, lang: Lang): Check[] { + if (facts.missing.length > 0) { + const problem: Problem = { key: "notConfigured", params: { missing: facts.missing.join(",") } }; + const detail = [problemText(lang, problem)]; + if (facts.missing.includes("cfApiToken")) detail.push(t(lang, "encryptionKeyHint")); + return [ + { id: "credentials", status: "problem", detail: detail.join(" ") }, + skipped("access", lang, "needsCredentials"), + skipped("siteTag", lang, "needsCredentials"), + skipped("hosts", lang, "needsCredentials"), + ]; + } + + const credentials: Check = { id: "credentials", status: "ok", detail: t(lang, "credentialsSaved") }; + const discovery = facts.discovery; + if (!discovery || !discovery.ok) { + const detail = discovery && !discovery.ok ? errorText(discovery, lang) : t(lang, "needsAccess"); + return [ + credentials, + { id: "access", status: "problem", detail }, + skipped("siteTag", lang, "needsAccess"), + skipped("hosts", lang, "needsAccess"), + ]; + } + + const access: Check = { id: "access", status: "ok", detail: t(lang, "accessOk") }; + const sites = discovery.value; + const days = DISCOVERY_DAYS; + + if (!facts.siteTag) { + return [ + credentials, + access, + { + id: "siteTag", + status: "problem", + detail: t(lang, sites.length > 0 ? "siteTagMissing" : "accountNoTraffic", { days }), + }, + skipped("hosts", lang, "needsSiteTag"), + ]; + } + + const site = sites.find((s) => s.siteTag === facts.siteTag); + if (!site) { + return [ + credentials, + access, + { + id: "siteTag", + status: "problem", + detail: t(lang, sites.length > 0 ? "siteTagNotFound" : "accountNoTraffic", { days }), + }, + skipped("hosts", lang, "needsSiteTag"), + ]; + } + + const siteTag: Check = { + id: "siteTag", + status: "ok", + detail: t(lang, "siteTagFound", { tag: site.siteTag, count: formatCount(site.pageviews ?? 0, lang), days }), + }; + return [credentials, access, siteTag, hostsCheck(facts, site, lang)]; +} + +function hostsCheck(facts: SetupFacts, site: Site, lang: Lang): Check { + const reported = site.hosts.map(normalizeHost); + const list = (hosts: string[]) => hosts.join(", "); + if (facts.hosts.length === 0) { + return { id: "hosts", status: "ok", detail: t(lang, "hostsEvery", { hosts: list(reported) }) }; + } + + const filter = new Set(facts.hosts.map(normalizeHost)); + const counted = reported.filter((host) => filter.has(host)); + const excluded = reported.filter((host) => !filter.has(host)); + if (counted.length === 0) { + return { + id: "hosts", + status: "problem", + detail: t(lang, "hostsNone", { reported: list(reported), counted: list([...filter]) }), + }; + } + if (excluded.length > 0) { + return { + id: "hosts", + status: "ok", + detail: t(lang, "hostsPartly", { counted: list(counted), excluded: list(excluded) }), + }; + } + return { id: "hosts", status: "ok", detail: t(lang, "hostsCounted", { hosts: list(counted) }) }; +} + +function siteUrlCheck(facts: SetupFacts, lang: Lang): Check { + if (!facts.siteUrl) return { id: "siteUrl", status: "problem", detail: t(lang, "siteUrlMissing") }; + return { id: "siteUrl", status: "ok", detail: facts.siteUrl }; +} + +function indexCheck(facts: SetupFacts, lang: Lang): Check { + const { state } = facts; + if (!state.indexComplete) return { id: "index", status: "waiting", detail: t(lang, "noEntriesIndexing") }; + const count = state.indexed ?? 0; + if (count > 0) return { id: "index", status: "ok", detail: t(lang, "indexMatched", { count: count }) }; + if (!facts.siteUrl) return skipped("index", lang, "needsSiteUrl"); + return { id: "index", status: "problem", detail: t(lang, "noEntriesNoUrls") }; +} + +function schedulerCheck(facts: SetupFacts, lang: Lang): Check { + if (!facts.tasks) return { id: "scheduler", status: "problem", detail: t(lang, "syncUnschedulable") }; + + const sync = facts.tasks.find((task) => task.name === SYNC_TASK); + if (!sync) return { id: "scheduler", status: "problem", detail: t(lang, "schedulerNotScheduled") }; + + const minutes = intervalMinutes(sync.schedule); + const interval = intervalText(minutes, lang); + const late = (iso: string | null | undefined, graceMs: number) => + iso !== null && iso !== undefined && facts.now.getTime() - Date.parse(iso) > graceMs; + const age = (iso: string) => formatAge(iso, facts.now, lang) ?? iso; + + const refresh = facts.tasks.find((task) => task.name === REFRESH_TASK); + if (refresh && late(refresh.nextRunAt, ONESHOT_GRACE_MS)) { + return { + id: "scheduler", + status: "problem", + detail: t(lang, "schedulerRefreshStuck", { age: age(refresh.nextRunAt) }), + }; + } + + const graceMs = 2 * minutes * 60_000; + if (sync.lastRunAt) { + if (late(sync.lastRunAt, graceMs)) { + return { + id: "scheduler", + status: "problem", + detail: t(lang, "schedulerStale", { age: age(sync.lastRunAt), interval }), + }; + } + return { id: "scheduler", status: "ok", detail: t(lang, "schedulerOk", { age: age(sync.lastRunAt), interval }) }; + } + + const { waitingSince } = facts; + if (!facts.state.lastSync && waitingSince && late(waitingSince, graceMs)) { + return { + id: "scheduler", + status: "problem", + detail: t(lang, "schedulerNeverRan", { age: age(waitingSince), interval }), + }; + } + return { id: "scheduler", status: "waiting", detail: t(lang, "schedulerWaiting", { interval }) }; +} + +function lastSyncCheck(facts: SetupFacts, lang: Lang): Check { + const { state, now } = facts; + if (state.lastError) { + const error = state.lastProblem ? problemText(lang, state.lastProblem) : state.lastError; + const at = formatAge(state.lastErrorAt, now, lang); + return { + id: "lastSync", + status: "problem", + detail: at ? t(lang, "lastAttemptFailedAge", { age: at, error }) : t(lang, "lastAttemptFailed", { error }), + }; + } + const age = formatAge(state.lastSync, now, lang); + if (!age) return { id: "lastSync", status: "waiting", detail: t(lang, "notSynced") }; + return { id: "lastSync", status: "ok", detail: t(lang, "synced", { age }) }; +} + +function skipped(id: CheckId, lang: Lang, reason: MessageKey): Check { + return { id, status: "skipped", detail: t(lang, reason) }; +} + +function errorText(result: { error: string; problem?: Problem }, lang: Lang): string { + return result.problem ? problemText(lang, result.problem) : result.error; +} + +/** + * Minutes between two runs of a sync interval. The settings offer four + * fixed schedules; anything else falls back to the default's fifteen. + */ +export function intervalMinutes(schedule: string): number { + const everyMinutes = /^\*\/(\d+) \* \* \* \*$/.exec(schedule); + if (everyMinutes) return Number(everyMinutes[1]); + if (/^0 \* \* \* \*$/.test(schedule)) return 60; + const everyHours = /^0 \*\/(\d+) \* \* \*$/.exec(schedule); + if (everyHours) return Number(everyHours[1]) * 60; + return 15; +} + +function intervalText(minutes: number, lang: Lang): string { + return minutes % 60 === 0 ? t(lang, "hours", { count: minutes / 60 }) : t(lang, "minutes", { count: minutes }); +} + +const CHECK_LABELS: Record = { + source: "checkSource", + credentials: "checkCredentials", + access: "checkAccess", + siteTag: "checkSiteTag", + hosts: "checkHosts", + siteUrl: "checkSiteUrl", + index: "checkIndex", + scheduler: "checkScheduler", + lastSync: "checkLastSync", +}; + +const STATUS_LABELS: Record = { + ok: "statusOk", + problem: "statusProblem", + waiting: "statusWaiting", + skipped: "statusSkipped", +}; + +const CRON_TRIGGER_SNIPPET = `// wrangler.jsonc +"triggers": { "crons": ["* * * * *"] }`; + +const SCHEDULED_HANDLER_SNIPPET = `// src/worker.ts +import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker"; + +export { PluginBridge }; + +export default { + ...handler, + scheduled: createScheduledHandler(), +} satisfies ExportedHandler;`; + +/** + * The check as blocks. `backValue` is the analytics page's range, carried + * so "Back to analytics" returns to the range the reader left. + */ +export function renderSetup(facts: SetupFacts, backValue: number, locale: string | undefined): AnalyticsBlock[] { + const lang = langOf(locale); + const checks = checkSetup(facts, locale); + const problems = checks.filter((check) => check.status === "problem").length; + + const out: AnalyticsBlock[] = [ + actions( + [ + button(RANGE_ACTION, t(lang, "backToAnalytics"), { style: "secondary", value: backValue }), + button(SETUP_ACTION, t(lang, "checkAgain"), { style: "secondary", value: backValue }), + ], + { blockId: "analytics:setup:controls" }, + ), + header(t(lang, "setupTitle")), + problems > 0 + ? banner({ description: t(lang, "setupProblems", { count: problems }), variant: "error" }) + : banner({ description: t(lang, "setupAllGood") }), + table({ + blockId: "analytics:setup:checks", + pageActionId: "analytics:setup:checks:page", + columns: [ + { key: "check", label: t(lang, "colCheck"), format: "text" }, + { key: "status", label: t(lang, "colStatus"), format: "badge" }, + { key: "detail", label: t(lang, "colDetails"), format: "text" }, + ], + rows: checks.map((check) => ({ + check: t(lang, CHECK_LABELS[check.id]), + status: t(lang, STATUS_LABELS[check.status]), + detail: check.detail, + })), + }), + ]; + + const scheduler = checks.find((check) => check.id === "scheduler"); + if (scheduler?.status === "problem" && facts.tasks) { + out.push(context(t(lang, "schedulerHowTo"))); + out.push(code(CRON_TRIGGER_SNIPPET, { language: "jsonc" })); + out.push(code(SCHEDULED_HANDLER_SNIPPET, { language: "ts" })); + } + + const sites = facts.discovery?.ok ? facts.discovery.value : []; + const siteTag = checks.find((check) => check.id === "siteTag"); + const hosts = checks.find((check) => check.id === "hosts"); + if (sites.length > 0 && (siteTag?.status === "problem" || hosts?.status === "problem")) { + out.push(header(t(lang, "sitesTitle"))); + out.push( + table({ + blockId: "analytics:setup:sites", + pageActionId: "analytics:setup:sites:page", + columns: [ + { key: "tag", label: t(lang, "colSiteTag"), format: "code" }, + { key: "hosts", label: t(lang, "colHostnames"), format: "text" }, + { key: "views", label: t(lang, "colViews"), format: "number" }, + ], + rows: sites.slice(0, SITES_SHOWN).map((site) => ({ + tag: site.siteTag, + hosts: site.hosts.join(", "), + views: site.pageviews ?? 0, + })), + }), + ); + out.push(context(t(lang, "sitesNote", { days: DISCOVERY_DAYS }))); + } + + return out; +} diff --git a/packages/analytics/src/ui/status.ts b/packages/analytics/src/ui/status.ts new file mode 100644 index 0000000..2c4a681 --- /dev/null +++ b/packages/analytics/src/ui/status.ts @@ -0,0 +1,65 @@ +/** + * The sentences the widget and the analytics page share: how fresh the + * numbers are, what is wrong if anything is, and why a store is empty. + */ + +import { langOf, problemText, t } from "../i18n.js"; +import type { SyncState } from "../sync/scheduler.js"; +import { formatAge, qualifier } from "./format.js"; + +/** + * One line saying how fresh the numbers are and what is wrong, if + * anything. Every failure mode that would otherwise render as a dashboard + * of zeroes should read as a sentence naming the fix. + */ +export function statusLine( + state: SyncState, + now: Date, + locale: string | undefined, + flags: { estimated?: boolean; provisional?: boolean; unmatched?: boolean }, +): string | null { + const lang = langOf(locale); + const parts: string[] = []; + if (state.provider === "demo") parts.push(t(lang, "demoData")); + + const age = formatAge(state.lastSync, now, lang); + parts.push(age ? t(lang, "synced", { age }) : t(lang, "notSynced")); + + const marks = qualifier({ ...flags, provider: state.provider }, lang); + if (marks) parts.push(marks); + if (flags.unmatched && nothingMatched(state)) parts.push(t(lang, "nothingMatched")); + + const error = errorText(state, lang); + if (error) { + const errorAge = formatAge(state.lastErrorAt, now, lang); + parts.push( + errorAge ? t(lang, "lastAttemptFailedAge", { age: errorAge, error }) : t(lang, "lastAttemptFailed", { error }), + ); + } + + return parts.length > 0 ? parts.join(" · ") : null; +} + +export function emptyReason(state: SyncState, now: Date, locale: string | undefined): string { + const lang = langOf(locale); + const error = errorText(state, lang); + if (error) return error; + if (!state.lastSync) return t(lang, "firstSyncPending"); + const age = formatAge(state.lastSync, now, lang); + return t(lang, "syncedNoViews", { age: age ?? t(lang, "recently") }); +} + +/** The stored failure in the reader's language; an unrecognized one as it came. */ +function errorText(state: SyncState, lang: ReturnType): string | undefined { + if (!state.lastError) return undefined; + return state.lastProblem ? problemText(lang, state.lastProblem) : state.lastError; +} + +/** + * The index walk finished without matching a single entry. Paths still + * show, untitled, so without saying so the numbers look plausible while + * every per-entry figure stays empty. + */ +export function nothingMatched(state: SyncState): boolean { + return Boolean(state.indexComplete) && (state.indexed ?? 0) === 0; +} diff --git a/packages/analytics/src/ui/widget.ts b/packages/analytics/src/ui/widget.ts new file mode 100644 index 0000000..2d33a09 --- /dev/null +++ b/packages/analytics/src/ui/widget.ts @@ -0,0 +1,185 @@ +/** + * The dashboard widget. + * + * Reads storage only. No provider call happens during a render: the + * widget dispatches one `page_load` per mount with no polling and no + * server cache, and `cacheControl` is stripped for private routes, so a + * render that called Cloudflare would call it once per editor per page + * view and still show whatever arrived last. + * + * Blocks come from `./blocks.js`, whose constructors return upstream's own + * block interfaces and emit snake_case keys. Hand-written camelCase keys + * are silently ignored by the renderer — that is the live bug in upstream's + * own `audit-log` widget, where "Load more" and the empty text never + * render. `tests/widget.test.ts` runs the host's real `validateBlocks()` + * over this output, so that class of mistake fails a test rather than + * shipping. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { actions, button, context, empty, link, stats, table, type AnalyticsBlock } from "./blocks.js"; +import { entriesStore, rollupStore, BIND_LIMIT } from "../store/access.js"; +import { historyReaches, type EntryRow, type RollupRow } from "../store/rows.js"; +import { addDays, daysBetween, utcDay, type Day } from "../sync/window.js"; +import { WIDGET_DAYS, type SyncState } from "../sync/scheduler.js"; +import { langOf, t, type Lang } from "../i18n.js"; +import { comparisonText, formatCount, trendOf } from "./format.js"; +import { PAGE_PATH } from "./page.js"; +import { emptyReason, statusLine } from "./status.js"; + +export const REFRESH_ACTION = "analytics:refresh"; + +export interface WidgetInput { + state: SyncState; + rollups: RollupRow[]; + entriesByPath: Map; + now: Date; + locale?: string; +} + +/** + * Gather what the widget needs: two storage reads, no more. + * + * `rollup.query` is bounded by the 14 days the comparison needs, well + * inside the 100-row clamp, and the entry lookup is a single `getMany` + * over the five paths actually being shown. + */ +export async function loadWidget(ctx: PluginContext, state: SyncState, now: Date, locale?: string): Promise { + const rollup = rollupStore(ctx); + const entries = entriesStore(ctx); + + const since = addDays(utcDay(now), -(WIDGET_DAYS * 2 - 1)); + const page = await rollup?.query({ + where: { date: { gte: since } }, + orderBy: { date: "asc" }, + limit: BIND_LIMIT, + }); + + const paths = (state.topPaths ?? []).slice(0, 5).map((p) => p.path); + const entriesByPath = paths.length > 0 && entries ? await entries.getMany(paths) : new Map(); + + return { + state, + rollups: (page?.items ?? []).map((item) => item.data), + entriesByPath, + now, + locale, + }; +} + +export function renderWidget(input: WidgetInput): AnalyticsBlock[] { + const { state, rollups, entriesByPath, now, locale } = input; + const lang = langOf(locale); + const today = utcDay(now); + + const current = sumRange(rollups, addDays(today, -(WIDGET_DAYS - 1)), today); + const previousStart = addDays(today, -(WIDGET_DAYS * 2 - 1)); + const previous = sumRange(rollups, previousStart, addDays(today, -WIDGET_DAYS)); + + // A previous period the store does not fully reach is "no history yet", + // which is different from "zero traffic" and must not render as a trend. + const hasPrevious = historyReaches(rollups, previousStart); + + const out: AnalyticsBlock[] = []; + + if (rollups.length === 0) { + out.push( + empty({ + title: t(lang, "noAnalyticsYet"), + description: emptyReason(state, now, lang), + }), + ); + out.push(refreshRow(lang)); + return out; + } + + const visitsTrend = trendOf(current.visits, hasPrevious ? previous.visits : null); + const viewsTrend = trendOf(current.pageviews, hasPrevious ? previous.pageviews : null); + + out.push( + stats([ + { + label: t(lang, "visitsLastDays", { days: WIDGET_DAYS }), + value: formatCount(current.visits, lang), + description: comparisonText(current.visits, hasPrevious ? previous.visits : null, lang), + ...(visitsTrend ? { trend: visitsTrend } : {}), + }, + { + label: t(lang, "pageviewsLastDays", { days: WIDGET_DAYS }), + value: formatCount(current.pageviews, lang), + description: comparisonText(current.pageviews, hasPrevious ? previous.pageviews : null, lang), + ...(viewsTrend ? { trend: viewsTrend } : {}), + }, + ]), + ); + + const topPaths = state.topPaths ?? []; + if (topPaths.length > 0) { + out.push( + table({ + blockId: "analytics:top-paths", + pageActionId: "analytics:top-paths:page", + columns: [ + { key: "page", label: t(lang, "colPage"), format: "text" }, + { key: "path", label: t(lang, "colPath"), format: "code" }, + { key: "views", label: t(lang, "colViews"), format: "number" }, + ], + rows: topPaths.slice(0, 5).map((row) => ({ + // The entry title where the join knows it, the raw path + // otherwise: plenty of real traffic hits paths that are not + // entries at all, and those are still worth showing. + page: entriesByPath.get(row.path)?.title || "—", + path: row.path, + views: row.pageviews, + })), + emptyText: t(lang, "noPagesYet"), + }), + ); + } + + const note = statusLine(state, now, lang, { + estimated: state.estimated || current.estimated, + provisional: current.provisional, + unmatched: true, + }); + if (note) out.push(context(note)); + + out.push(refreshRow(lang)); + return out; +} + +function refreshRow(lang: Lang) { + return actions([ + button(REFRESH_ACTION, t(lang, "refresh"), { style: "secondary" }), + link(t(lang, "openAnalytics"), { kind: "plugin-page", path: PAGE_PATH }, { appearance: "secondary" }), + ]); +} + +/** + * Sum the rollup rows inside an inclusive day range. + * + * `days` counts rows that exist, not days in the range: a day Cloudflare + * never reported is absent rather than zero, and treating absence as zero + * is what makes a fresh install look like a traffic collapse. + */ +function sumRange(rows: RollupRow[], since: Day, until: Day) { + let pageviews = 0; + let visits = 0; + let days = 0; + let estimated = false; + let provisional = false; + const today = until; + + for (const row of rows) { + if (daysBetween(since, row.date) < 0) continue; + if (daysBetween(row.date, until) < 0) continue; + pageviews += row.pageviews; + visits += row.visits; + days++; + if (row.sampleInterval > 1) estimated = true; + if (row.date === today) provisional = true; + } + + return { pageviews, visits, days, estimated, provisional }; +} diff --git a/packages/analytics/tests/astro.test.ts b/packages/analytics/tests/astro.test.ts new file mode 100644 index 0000000..7581aab --- /dev/null +++ b/packages/analytics/tests/astro.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from "vitest"; + +import { analyticsBeacon, buildBeaconScript } from "../src/astro/index.js"; + +type Setup = Parameters["hooks"]["astro:config:setup"]>[0]; + +function run(options: Parameters[0], command: Setup["command"] = "build") { + const injectScript = vi.fn(); + const warn = vi.fn(); + const info = vi.fn(); + analyticsBeacon(options).hooks["astro:config:setup"]({ + command, + injectScript, + logger: { warn, info }, + }); + return { injectScript, warn, info }; +} + +describe("when the beacon is injected", () => { + it("injects at head-inline on a build", () => { + const { injectScript } = run({ token: "tok" }); + expect(injectScript).toHaveBeenCalledTimes(1); + expect(injectScript.mock.calls[0]![0]).toBe("head-inline"); + }); + + it("warns and injects nothing without a token", () => { + // A silent no-op here is a site that collects nothing and looks fine. + const { injectScript, warn } = run({}); + expect(injectScript).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toMatch(/token/); + // And it names the token/tag confusion, which is the actual trap. + expect(warn.mock.calls[0]![0]).toMatch(/not the site tag/i); + }); + + it("skips astro dev by default", () => { + // A dev server reports under the same site tag as production. + const { injectScript, info } = run({ token: "tok" }, "dev"); + expect(injectScript).not.toHaveBeenCalled(); + expect(info).toHaveBeenCalled(); + }); + + it("injects in dev when asked explicitly", () => { + const { injectScript } = run({ token: "tok", includeDev: true }, "dev"); + expect(injectScript).toHaveBeenCalledTimes(1); + }); + + it("respects a production flag env var", () => { + const name = "ANALYTICS_TEST_PROD_FLAG"; + delete process.env[name]; + expect(run({ token: "tok", productionFlagEnv: name }).injectScript).not.toHaveBeenCalled(); + + process.env[name] = "false"; + expect(run({ token: "tok", productionFlagEnv: name }).injectScript).not.toHaveBeenCalled(); + + process.env[name] = "true"; + expect(run({ token: "tok", productionFlagEnv: name }).injectScript).toHaveBeenCalledTimes(1); + delete process.env[name]; + }); + + it("refuses an unknown provider rather than guessing", () => { + const { injectScript, warn } = run({ provider: "plausible" as "cloudflare", token: "tok" }); + expect(injectScript).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalled(); + }); +}); + +describe("the injected script", () => { + it("is syntactically valid JavaScript", () => { + // It runs in every visitor's browser; a syntax error is not something + // a type-checker would have caught. + const code = buildBeaconScript({ token: "tok" }); + expect(() => new Function(code)).not.toThrow(); + }); + + it("bails out on the admin before creating anything", () => { + const code = buildBeaconScript({ token: "tok" }); + expect(code).toContain('"/_emdash"'); + expect(code).toMatch(/indexOf\("\/_emdash" \+ "\/"\) === 0/); + }); + + it("actually skips injection on an admin path when executed", () => { + expect(runInFakeDom(buildBeaconScript({ token: "tok" }), "/_emdash/admin/dashboard").appended).toBe(0); + expect(runInFakeDom(buildBeaconScript({ token: "tok" }), "/_emdashboard/").appended).toBe(1); + expect(runInFakeDom(buildBeaconScript({ token: "tok" }), "/blog/").appended).toBe(1); + }); + + it("carries the token in the beacon attribute", () => { + const dom = runInFakeDom(buildBeaconScript({ token: "abc123" }), "/"); + expect(dom.attributes["data-cf-beacon"]).toBe('{"token":"abc123"}'); + expect(dom.src).toBe("https://static.cloudflareinsights.com/beacon.min.js"); + expect(dom.defer).toBe(true); + }); + + it("only emits spa when explicitly disabled", () => { + expect(runInFakeDom(buildBeaconScript({ token: "t" }), "/").attributes["data-cf-beacon"]).toBe('{"token":"t"}'); + expect(runInFakeDom(buildBeaconScript({ token: "t", spa: false }), "/").attributes["data-cf-beacon"]).toBe( + '{"token":"t","spa":false}', + ); + }); + + it("cannot be terminated early by a crafted token", () => { + // A token containing would otherwise close the inline + // element and dump the rest of the code into the document as text. + const code = buildBeaconScript({ token: "a" }); + expect(code).not.toContain(""); + expect(() => new Function(code)).not.toThrow(); + }); + + it("waits for the consent event when a gate is configured", () => { + const code = buildBeaconScript({ token: "t", consent: { event: "emdash:consent:analytics" } }); + const dom = runInFakeDom(code, "/"); + expect(dom.appended).toBe(0); + expect(dom.listeners["emdash:consent:analytics"]).toHaveLength(1); + + dom.listeners["emdash:consent:analytics"]![0]!(); + expect(dom.appended).toBe(1); + }); + + it("injects immediately when the granted flag is already set", () => { + const code = buildBeaconScript({ + token: "t", + consent: { event: "emdash:consent:analytics", grantedFlag: "analyticsConsented" }, + }); + expect(runInFakeDom(code, "/", { analyticsConsented: true }).appended).toBe(1); + expect(runInFakeDom(code, "/", { analyticsConsented: false }).appended).toBe(0); + expect(runInFakeDom(code, "/", { analyticsConsented: () => true }).appended).toBe(1); + }); + + it("never appends twice, however often the event fires", () => { + const code = buildBeaconScript({ token: "t", consent: { event: "go" } }); + const dom = runInFakeDom(code, "/"); + dom.listeners["go"]![0]!(); + dom.listeners["go"]![0]!(); + expect(dom.appended).toBe(1); + }); + + it("swallows its own errors rather than logging in every visitor's console", () => { + const code = buildBeaconScript({ token: "t" }); + const fn = new Function("location", "document", "window", code); + const hostileDocument = { + createElement() { + throw new Error("nope"); + }, + }; + // The script must not propagate a DOM failure: an exception from an + // inline head script blocks nothing, but it does land in the console + // of every visitor. + expect(() => fn({ pathname: "/" }, hostileDocument, {})).not.toThrow(); + }); +}); + +/** + * Executes the injected script against a minimal fake DOM and reports what + * it did. Testing the string is not enough — the point is the behaviour in + * a browser. + */ +function runInFakeDom(code: string, pathname: string, windowExtras: Record = {}) { + const state = { + appended: 0, + src: "", + defer: false, + attributes: {} as Record, + listeners: {} as Record void>>, + }; + + const element = { + set src(value: string) { + state.src = value; + }, + get src() { + return state.src; + }, + set defer(value: boolean) { + state.defer = value; + }, + get defer() { + return state.defer; + }, + setAttribute(name: string, value: string) { + state.attributes[name] = value; + }, + }; + + const document = { + createElement: () => element, + head: { + appendChild: () => { + state.appended++; + }, + }, + documentElement: { + appendChild: () => { + state.appended++; + }, + }, + }; + + const windowObj: Record = { + ...windowExtras, + addEventListener(event: string, handler: () => void) { + (state.listeners[event] ??= []).push(handler); + }, + }; + + const fn = new Function("location", "document", "window", code); + fn({ pathname }, document, windowObj); + return state; +} diff --git a/packages/analytics/tests/backfill.test.ts b/packages/analytics/tests/backfill.test.ts new file mode 100644 index 0000000..d2aceab --- /dev/null +++ b/packages/analytics/tests/backfill.test.ts @@ -0,0 +1,61 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GRAPHQL_ENDPOINT } from "../src/providers/cloudflare.js"; +import { addDays, utcDay } from "../src/sync/window.js"; + +/** + * The Cloudflare adapter through the production bridge, against faked + * GraphQL responses. In its own file because plugin state outlives + * `dispose()` within a file, and this test is about the very first tick. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +const EMPTY_OVERVIEW = { data: { viewer: { accounts: [{ totals: [], series: [], pages: [], refs: [], geo: [] }] } } }; + +function graphqlReply() { + return new Response(JSON.stringify(EMPTY_OVERVIEW), { headers: { "Content-Type": "application/json" } }); +} + +function sentSince(index: number): unknown { + const request = host!.http.requests()[index]; + if (!request) return undefined; + const body = JSON.parse(new TextDecoder().decode(request.body)) as { variables: { since?: string } }; + return body.variables.since; +} + +describe("the first sync", () => { + it("reaches back to the edge of the exact window once, then re-reads the widget's week", async () => { + // A fresh install used to show two days of history. Seven is as far + // back as Cloudflare stays exact, and no further. + vi.stubEnv("EMDASH_ENCRYPTION_KEY", "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + host = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + const saved = await host.actions.plugin.updateSettings({ + cfApiToken: "cfat_test", + cfAccountId: "acct-1", + cfSiteTag: "tag-1", + }); + expect(saved).toMatchObject({ success: true }); + const today = utcDay(new Date()); + + // Tick 1: overview with backfill. Tick 2: indexing, no request. + // Tick 3: overview again, now over the regular window. + await host.http.respond(GRAPHQL_ENDPOINT, graphqlReply()); + await host.http.respond(GRAPHQL_ENDPOINT, graphqlReply()); + for (let i = 0; i < 3; i++) { + await host.transport.invokeHook("cron", { name: "sync", scheduledAt: new Date().toISOString() }); + } + + expect(sentSince(0)).toBe(addDays(today, -7)); + expect(sentSince(1)).toBe(addDays(today, -6)); + }); +}); diff --git a/packages/analytics/tests/bridge-calls.ts b/packages/analytics/tests/bridge-calls.ts new file mode 100644 index 0000000..850b1e7 --- /dev/null +++ b/packages/analytics/tests/bridge-calls.ts @@ -0,0 +1,55 @@ +import { PluginBridge } from "@emdash-cms/plugin-test/worker"; + +/** + * The bridge calls a sandboxed invocation makes. + * + * On Cloudflare every `ctx` call crosses the bridge as one subrequest, and + * EmDash gives a sandboxed invocation ten; the eleventh aborts it. The + * local harness enforces no limit, so this wraps the class those calls + * land on, the harness's own `PluginBridge`. workerd constructs a fresh + * entrypoint instance for every incoming RPC call, so the first method + * that runs on an instance is the call; later ones on the same instance + * are the bridge using its own helpers. + */ + +const calls: string[] = []; +const seen = new WeakSet(); +const failing = new Set(); +let installed = false; + +function install(): void { + if (installed) return; + installed = true; + const proto = PluginBridge.prototype as unknown as Record; + for (const name of Object.getOwnPropertyNames(proto)) { + const original = Object.getOwnPropertyDescriptor(proto, name)?.value; + if (name === "constructor" || typeof original !== "function") continue; + proto[name] = function (this: object, ...args: unknown[]) { + if (!seen.has(this)) { + seen.add(this); + calls.push(name); + if (failing.delete(name)) return Promise.reject(new Error(`${name} failed (test)`)); + } + return (original as (...a: unknown[]) => unknown).apply(this, args); + }; + } +} + +/** Run one invocation and return the bridge calls it made, in order. */ +export async function bridgeCalls(invocation: () => Promise): Promise { + install(); + calls.length = 0; + await invocation(); + return [...calls]; +} + +/** + * Make the next call to a bridge method fail, as a lost subrequest would. + * + * The harness's RPC wrapper also prints the rejection as an uncaught + * exception. The plugin still receives it; the test run is unaffected. + */ +export function failNextCall(method: string): void { + install(); + failing.add(method); +} diff --git a/packages/analytics/tests/budget.test.ts b/packages/analytics/tests/budget.test.ts new file mode 100644 index 0000000..8855870 --- /dev/null +++ b/packages/analytics/tests/budget.test.ts @@ -0,0 +1,339 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GRAPHQL_ENDPOINT, RUM_DATASET } from "../src/providers/cloudflare.js"; +import { WAITING_KEY, type SyncState } from "../src/sync/scheduler.js"; +import { addDays } from "../src/sync/window.js"; +import { PAGE_REFRESH_ACTION, RANGE_ACTION, SETUP_ACTION } from "../src/ui/page.js"; +import { bridgeCalls, failNextCall } from "./bridge-calls.js"; +import { + daysBack, + graphql, + newHost, + NOW, + pathsOf, + routableCollection, + seedDaily, + seedEntries, + seedRollup, + setState, + synced, + tick, + TODAY, +} from "./host.js"; + +/** + * Every invocation, at its worst case, against EmDash's sandbox limit. + * + * A sandboxed invocation may make ten subrequests and each `ctx` call is + * one; the eleventh throws "Too many subrequests" on Cloudflare and aborts + * the tick or the render. The harness does not enforce the limit, so each + * test here counts the calls and also checks the invocation did its full + * share of work, so a fixture too small to reach the worst case fails + * instead of passing quietly. + */ + +const LIMIT = 10; + +/** The most paths a paths tick asks about; the fixture below covers exactly that many. */ +const PATHS_PER_TICK = 36; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +describe("sync ticks", () => { + it("an overview tick", async () => { + host = await newHost("cloudflare"); + const series = daysBack(8).map((date) => ({ + count: 10, + sum: { visits: 5 }, + avg: { sampleInterval: 1 }, + dimensions: { date }, + })); + await host.http.respond( + GRAPHQL_ENDPOINT, + graphql({ totals: [{ count: 80, sum: { visits: 40 } }], series, pages: [], refs: [], geo: [] }), + ); + + const calls = await bridgeCalls(tick(host)); + + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + await expect(host.inspect.storage.get("rollup", addDays(TODAY, -7))).resolves.not.toBeNull(); + }); + + it("an index tick", async () => { + host = await newHost(); + await routableCollection(host, 5); + await setState(host, { ...synced, phase: "paths", indexComplete: false }); + + const calls = await bridgeCalls(tick(host)); + + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + const state = await host.inspect.kv.get("state"); + expect(state?.lastError).toBeUndefined(); + expect(state?.indexed).toBeGreaterThanOrEqual(3); + }); + + it("a paths tick", async () => { + host = await newHost("cloudflare"); + // More entries than one tick may ask about, each with traffic on every + // day of the window, none of it stored yet. + const paths = pathsOf(40); + await seedEntries(host, paths); + await setState(host, { ...synced, phase: "paths", provider: "cloudflare" }); + + const window = daysBack(8); + const asked = paths.slice(0, PATHS_PER_TICK); + const rows = asked.flatMap((path) => + window.map((date) => ({ + count: 7, + sum: { visits: 3 }, + avg: { sampleInterval: 1 }, + dimensions: { date, requestPath: path }, + })), + ); + await host.http.respond(GRAPHQL_ENDPOINT, graphql({ [RUM_DATASET]: rows })); + + const calls = await bridgeCalls(tick(host)); + + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + const sent = JSON.parse(new TextDecoder().decode(host.http.requests()[0]!.body)) as { + variables: { paths: string[] }; + }; + expect(sent.variables.paths.filter((path) => path.endsWith("/"))).toEqual(asked); + await expect(host.inspect.storage.get("daily", `${window[7]}|${asked[35]}`)).resolves.not.toBeNull(); + // Eight days of seven views: the last seven make views7, all eight the + // recent part of views30. + await expect(host.inspect.storage.get("entries", asked[35]!)).resolves.toMatchObject({ views7: 49, views30: 56 }); + }); + + it("every step of an older pass over more paths than one step can finish", async () => { + // One row per path is the expensive shape: every row read finishes a + // path, so every page can add a hundred entries to look up and write. + host = await newHost(); + const paths = pathsOf(300, "/q-"); + await seedEntries(host, paths); + await seedDaily(host, paths, [addDays(TODAY, -20)]); + await setState(host, { ...synced, phase: "paths", lastWork: "paths", older: { day: addDays(TODAY, -1), complete: true } }); + + let steps = 0; + for (let i = 0; i < 60; i++) { + const before = await host.inspect.kv.get("state"); + if (before?.older?.day === TODAY && before.older.complete) break; + const calls = await bridgeCalls(tick(host)); + expect(calls.length, `tick ${i}: ${calls.join(", ")}`).toBeLessThanOrEqual(LIMIT); + if ((await host.inspect.kv.get("state"))?.lastWork === "older" && before?.lastWork !== "older") steps++; + } + + expect(steps).toBeGreaterThan(1); + await expect(host.inspect.storage.get("entries", paths[299]!)).resolves.toMatchObject({ olderViews: 3 }); + }); + + it("a Refresh tick", async () => { + host = await newHost(); + await setState(host, { ...synced, phase: "paths" }); + const calls = await bridgeCalls(tick(host, "refresh")); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + await expect(host.inspect.storage.get("rollup", TODAY)).resolves.not.toBeNull(); + }); + + it("every tick of a provider-switch wipe, until it completes", async () => { + host = await newHost(); + await host.fixtures.plugin.setting("provider", "cloudflare"); + const paths = pathsOf(40); + await seedEntries(host, paths, 12); + await seedRollup(host, 150); + await seedDaily(host, paths, daysBack(10)); + await setState(host, synced); + + let ticks = 0; + let state: SyncState | null = null; + for (; ticks < 20 && state?.provider !== "cloudflare"; ticks++) { + const calls = await bridgeCalls(tick(host)); + expect(calls.length, `tick ${ticks}: ${calls.join(", ")}`).toBeLessThanOrEqual(LIMIT); + state = await host.inspect.kv.get("state"); + } + + expect(state?.provider).toBe("cloudflare"); + expect(ticks).toBeGreaterThan(1); + await expect(host.inspect.storage.list("rollup")).resolves.toEqual([]); + await expect(host.inspect.storage.list("daily")).resolves.toEqual([]); + await expect(host.inspect.storage.get("entries", paths[39]!)).resolves.toMatchObject({ views7: 0, views30: 0 }); + }); + + it("a reconcile run with more to prune than one run can delete", async () => { + host = await newHost(); + const old = Array.from({ length: 10 }, (_, i) => addDays(TODAY, -200 - i)); + await seedDaily(host, pathsOf(50), old); + + const calls = await bridgeCalls(tick(host, "reconcile")); + + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + const left = (await host.inspect.storage.list("daily")).length; + expect(left).toBeGreaterThan(0); + expect(left).toBeLessThan(500); + }); +}); + +describe("admin requests", () => { + /** Five top paths, a full comparison window, and 90 days of per-entry rows. */ + async function withData(runtime: PluginRuntimeTestHost) { + const paths = pathsOf(5); + await seedEntries(runtime, paths); + await seedRollup(runtime, 180); + await seedDaily(runtime, paths, daysBack(60)); + await setState(runtime, { + ...synced, + topPaths: paths.map((path) => ({ path, pageviews: 10, visits: 5 })), + }); + } + + it("a widget load", async () => { + host = await newHost(); + await withData(host); + const calls = await bridgeCalls(() => host!.admin.loadWidget("traffic")); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(calls).toContain("cronSchedule"); + }); + + it("a widget load before the first sync, which marks the wait", async () => { + host = await newHost(); + await withData(host); + await setState(host, { phase: "overview", topPaths: pathsOf(5).map((path) => ({ path, pageviews: 10, visits: 5 })) }); + const calls = await bridgeCalls(() => host!.admin.loadWidget("traffic")); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(await host.inspect.kv.get(WAITING_KEY)).toEqual(expect.any(String)); + }); + + it("a setup check on a Cloudflare site that has not synced yet", async () => { + host = await newHost("cloudflare"); + await host.http.respond( + GRAPHQL_ENDPOINT, + graphql({ [RUM_DATASET]: [{ count: 5, dimensions: { siteTag: "tag-1", requestHost: "example.test" } }] }), + ); + const calls = await bridgeCalls(() => host!.admin.act("/analytics", SETUP_ACTION, { value: 30 })); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(host.http.requests()).toHaveLength(1); + }); + + it("a widget Refresh", async () => { + host = await newHost(); + await withData(host); + const calls = await bridgeCalls(() => host!.admin.act("widget:traffic", "analytics:refresh")); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect((await host.inspect.scheduledTasks()).map((t) => String(t.name))).toContain("refresh"); + }); + + it("an analytics page load over 90 days, answered live", async () => { + host = await newHost(); + await withData(host); + const calls = await bridgeCalls(() => host!.admin.act("/analytics", RANGE_ACTION, { value: 90 })); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(calls).toContain("storageGetMany"); + }); + + it("an analytics page Refresh over 90 days, falling back to the store", async () => { + // The provider fails, so the page reads rollups and daily rows as + // well: its most expensive path. + host = await newHost("cloudflare"); + await withData(host); + await host.http.respond(GRAPHQL_ENDPOINT, new Response("upstream down", { status: 502 })); + let toast: unknown; + const calls = await bridgeCalls(async () => { + toast = (await host!.admin.act("/analytics", PAGE_REFRESH_ACTION, { value: 90 })).toast; + }); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(toast).toMatchObject({ type: "error" }); + expect(calls.filter((c) => c === "storageQuery").length).toBeGreaterThanOrEqual(5); + }); +}); + +describe("content hooks", () => { + it("a save, which also schedules", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + const calls = await bridgeCalls(() => + host!.transport.invokeHook("content:afterSave", { + collection: "posts", + content: { id, title: "Post 0" }, + isNew: false, + }), + ); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + await expect(host.inspect.storage.get("entries", "/blog/post-0/")).resolves.toMatchObject({ entryId: id }); + }); + + it("an unpublish", async () => { + host = await newHost(); + await seedEntries(host, ["/blog/post-0/"]); + const calls = await bridgeCalls(() => + host!.transport.invokeHook("content:afterUnpublish", { collection: "posts", content: { id: "id/blog/post-0/" } }), + ); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + await expect(host.inspect.storage.get("entries", "/blog/post-0/")).resolves.toMatchObject({ + status: "unpublished", + }); + }); +}); + +describe("the index walk", () => { + it("indexes every published entry across ticks and skips drafts", async () => { + host = await newHost(); + const ids = await routableCollection(host, 7, 2); + await setState(host, { ...synced, phase: "paths", indexComplete: false }); + + for (let i = 0; i < 20 && !(await host.inspect.kv.get("state"))?.indexComplete; i++) { + await tick(host)(); + } + + const rows = await host.inspect.storage.list<{ entryId: string }>("entries"); + expect(rows.map((r) => r.data.entryId).sort()).toEqual(ids.slice(0, 7).sort()); + }); + + it("files a translation under its original's group", async () => { + host = await newHost(); + const [original] = await routableCollection(host, 1); + const translation = await host.fixtures.content("posts", { + slug: "post-0-de", + locale: "de", + translationOf: original, + data: { title: "Beitrag 0" }, + status: "published", + publishedAt: NOW.toISOString(), + }); + await setState(host, { ...synced, phase: "paths", indexComplete: false }); + + for (let i = 0; i < 20 && !(await host.inspect.kv.get("state"))?.indexComplete; i++) { + await tick(host)(); + } + + const rows = await host.inspect.storage.list<{ entryId: string; translationGroup: string }>("entries"); + const groups = Object.fromEntries(rows.map((r) => [r.data.entryId, r.data.translationGroup])); + expect(groups).toEqual({ [original!]: original, [translation.id]: original }); + }); + + it("repeats a page whose tick failed instead of skipping it", async () => { + // The cursor is saved with the state after the page's rows. Saved + // before them, a tick that dies in between loses its page for good. + host = await newHost(); + const ids = await routableCollection(host, 5); + await setState(host, { ...synced, phase: "paths", indexComplete: false }); + + failNextCall("storagePutMany"); + await tick(host)(); + expect((await host.inspect.kv.get("state"))?.lastProblem?.key).toBe("indexingFailed"); + await expect(host.inspect.storage.list("entries")).resolves.toEqual([]); + + for (let i = 0; i < 20 && !(await host.inspect.kv.get("state"))?.indexComplete; i++) { + await tick(host)(); + } + + const rows = await host.inspect.storage.list<{ entryId: string }>("entries"); + expect(rows.map((r) => r.data.entryId).sort()).toEqual([...ids].sort()); + }); +}); diff --git a/packages/analytics/tests/cloudflare.test.ts b/packages/analytics/tests/cloudflare.test.ts new file mode 100644 index 0000000..c036c63 --- /dev/null +++ b/packages/analytics/tests/cloudflare.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it } from "vitest"; + +import { + cloudflareDashboardUrl, + createCloudflareProvider, + DIRECT_REFERRER, + RUM_DATASET, +} from "../src/providers/cloudflare.js"; +import type { FetchLike } from "../src/providers/types.js"; + +/** Captures what the adapter sent, and replies with what the test dictates. */ +function recorder(reply: unknown, status = 200) { + const calls: Array<{ query: string; variables: Record }> = []; + const fetch: FetchLike = async (_url, init) => { + const body = JSON.parse(String(init?.body ?? "{}")); + calls.push({ query: body.query, variables: body.variables }); + return new Response(typeof reply === "string" ? reply : JSON.stringify(reply), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; + return { calls, fetch }; +} + +const RANGE = { since: "2026-09-18", until: "2026-09-20" }; + +function provider(fetch: FetchLike, over: Partial[0]> = {}) { + return createCloudflareProvider({ + apiToken: "cfat_secret", + accountId: "acct-1", + siteTag: "tag-1", + hosts: ["example.com", "www.example.com"], + fetch, + ...over, + }); +} + +/** + * The dataset aliases' filters, so each can be asserted separately. The + * `accounts(filter: { accountTag: … })` wrapper is not one of them. + */ +function aliasFilters(query: string): string[] { + return [...query.matchAll(/filter: (\{[^}]*\})/g)].map((m) => m[1]!).filter((f) => !f.includes("accountTag")); +} + +const EMPTY_OK = { data: { viewer: { accounts: [{ totals: [], series: [], pages: [], refs: [], geo: [] }] } } }; + +describe("the two bugs the old package shipped", () => { + it("filters bots out of every single alias", async () => { + // The old query had no `bot: 0` anywhere, so bot hits were page views. + // Asserting "every alias" rather than "the query contains bot: 0" + // matters: a totals alias that filters differently from the series + // alias produces a chart whose columns do not add up to its header. + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch).overview(RANGE); + const filters = aliasFilters(calls[0]!.query); + expect(filters).toHaveLength(5); + for (const f of filters) expect(f).toContain("bot: 0"); + }); + + it("sends the site tag as siteTag, never as a beacon token", async () => { + // The old README told operators to paste the beacon `token` here. + // Confirmed live on 2026-09-20: site_tag and site_token differ on + // every site, so that query matched nothing at all. + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch, { siteTag: "ab3dbd732b894b9796fe16087182fe3f" }).overview(RANGE); + expect(calls[0]!.variables.siteTag).toBe("ab3dbd732b894b9796fe16087182fe3f"); + for (const f of aliasFilters(calls[0]!.query)) expect(f).toContain("siteTag: $siteTag"); + }); +}); + +describe("query construction", () => { + it("puts the host allow-list on every alias so previews are excluded", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch).overview(RANGE); + for (const f of aliasFilters(calls[0]!.query)) expect(f).toContain("requestHost_in: $hosts"); + // Both spellings survive: they are distinct requestHost values. + expect(calls[0]!.variables.hosts).toEqual(["example.com", "www.example.com"]); + }); + + it("omits the host variable entirely when no hosts are configured", async () => { + // Declaring an unused GraphQL variable is a hard error, and an empty + // `requestHost_in` would match nothing rather than everything. + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch, { hosts: [] }).overview(RANGE); + expect(calls[0]!.query).not.toContain("$hosts"); + expect(calls[0]!.variables.hosts).toBeUndefined(); + }); + + it("keeps admin page views out of every alias, the totals included", async () => { + // Cloudflare's automatic setup injects the beacon into every HTML + // response, `/_emdash/admin` included, so editor sessions reach the + // dataset. The totals alias has no path dimension: dropping rows after + // reading could clean up top pages but never the headline number. + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch).overview(RANGE); + for (const f of aliasFilters(calls[0]!.query)) expect(f).toContain("requestPath_notlike: $internal"); + expect(calls[0]!.variables.internal).toBe("/_emdash/%"); + }); + + it("selects the sampling factor wherever it selects a count", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch).overview(RANGE); + const q = calls[0]!.query; + expect(q).toContain("avg { sampleInterval }"); + expect(q.match(/avg \{ sampleInterval \}/g)!.length).toBeGreaterThanOrEqual(3); + }); + + it("names the verified dataset", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + await provider(fetch).overview(RANGE); + expect(RUM_DATASET).toBe("rumPageloadEventsAdaptiveGroups"); + expect(calls[0]!.query).toContain(RUM_DATASET); + }); +}); + +describe("the sampling guard", () => { + it("refuses a window that would come back tenfold-quantized", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + const res = await provider(fetch).overview({ since: "2026-09-06", until: "2026-09-20" }); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/10% sample/); + // And it refuses locally: no request is worth sending. + expect(calls).toHaveLength(0); + }); + + it("allows a window that sits exactly on the cliff", async () => { + const { fetch } = recorder(EMPTY_OK); + const res = await provider(fetch).overview({ since: "2026-09-13", until: "2026-09-20" }); + expect(res.ok).toBe(true); + }); + + it("rejects an inverted range", async () => { + const { fetch } = recorder(EMPTY_OK); + const res = await provider(fetch).overview({ since: "2026-09-20", until: "2026-09-18" }); + expect(res.ok).toBe(false); + }); +}); + +describe("per-path batching", () => { + it("sets a limit above paths x days, because limit truncates silently", async () => { + const { calls, fetch } = recorder({ data: { viewer: { accounts: [{ [RUM_DATASET]: [] }] } } }); + await provider(fetch).paths(["/a/", "/b/"], RANGE); + // 2 paths x 3 days = 6 groups; the limit must exceed that. + const limit = Number(/limit: (\d+)/.exec(calls[0]!.query)![1]); + expect(limit).toBeGreaterThan(6); + }); + + it("refuses a chunk whose groups would exceed the page size", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + const many = Array.from({ length: 4000 }, (_, i) => `/p${i}/`); + const res = await provider(fetch).paths(many, RANGE); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/group page size/); + expect(calls).toHaveLength(0); + }); + + it("reports truncation rather than returning a short answer as complete", async () => { + // Fill the response to exactly the limit the adapter asks for: two + // paths in two spellings over three days, plus its margin of ten. + const rows = Array.from({ length: 22 }, (_, i) => ({ + count: 1, + sum: { visits: 1 }, + avg: { sampleInterval: 1 }, + dimensions: { date: "2026-09-20", requestPath: `/p${i}/` }, + })); + const { fetch } = recorder({ data: { viewer: { accounts: [{ [RUM_DATASET]: rows }] } } }); + const res = await provider(fetch).paths(["/a/", "/b/"], RANGE); + expect(res.ok).toBe(false); + }); + + it("does not call the API for an empty path list", async () => { + const { calls, fetch } = recorder(EMPTY_OK); + const res = await provider(fetch).paths([], RANGE); + expect(res).toEqual({ ok: true, value: [] }); + expect(calls).toHaveLength(0); + }); + + it("asks for both spellings of each path, since the filter matches the path as requested", async () => { + // Measured on eisbachcode.de: Cloudflare reported /contact while the + // index held /contact/, and a filter on the index's spelling alone + // returned nothing for every page but the root. + const { calls, fetch } = recorder({ data: { viewer: { accounts: [{ [RUM_DATASET]: [] }] } } }); + await provider(fetch).paths(["/contact/", "/"], RANGE); + expect(new Set(calls[0]!.variables.paths as string[])).toEqual(new Set(["/contact", "/contact/", "/"])); + }); + + it("adds up both spellings' rows for the same day", async () => { + const row = (requestPath: string, count: number) => ({ + count, + sum: { visits: count }, + avg: { sampleInterval: 1 }, + dimensions: { date: "2026-09-20", requestPath }, + }); + const { fetch } = recorder({ + data: { viewer: { accounts: [{ [RUM_DATASET]: [row("/contact", 2), row("/contact/", 3)] }] } }, + }); + const res = await provider(fetch, { trailingSlash: "always" }).paths(["/contact/"], RANGE); + expect(res).toEqual({ + ok: true, + value: [{ date: "2026-09-20", path: "/contact/", pageviews: 5, visits: 5, sampleInterval: 1 }], + }); + }); + + it("normalizes the paths it returns", async () => { + const rows = [ + { count: 3, sum: { visits: 2 }, avg: { sampleInterval: 1 }, dimensions: { date: "2026-09-20", requestPath: "/datenschutz" } }, + ]; + const { fetch } = recorder({ data: { viewer: { accounts: [{ [RUM_DATASET]: rows }] } } }); + const res = await provider(fetch).paths(["/datenschutz/"], RANGE); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value[0]!.path).toBe("/datenschutz/"); + }); +}); + +describe("parsing", () => { + it("adds up two spellings that normalize to one path", async () => { + // Real shape from the 2026-09-20 pull: /datenschutz and + // /datenschutz/ arrived as separate rows for one page. Letting one + // win would halve the number. + const { fetch } = recorder({ + data: { + viewer: { + accounts: [ + { + totals: [{ count: 4, sum: { visits: 3 }, avg: { sampleInterval: 1 } }], + series: [], + pages: [ + { count: 1, sum: { visits: 1 }, avg: { sampleInterval: 1 }, dimensions: { requestPath: "/datenschutz" } }, + { count: 3, sum: { visits: 2 }, avg: { sampleInterval: 1 }, dimensions: { requestPath: "/datenschutz/" } }, + ], + refs: [], + geo: [], + }, + ], + }, + }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.value.topPaths).toHaveLength(1); + expect(res.value.topPaths[0]).toMatchObject({ path: "/datenschutz/", pageviews: 4, visits: 3 }); + }); + + it("drops the bare admin path, which the query pattern does not cover", async () => { + const row = (requestPath: string) => ({ + count: 2, + sum: { visits: 1 }, + avg: { sampleInterval: 1 }, + dimensions: { requestPath }, + }); + const { fetch } = recorder({ + data: { + viewer: { + accounts: [{ totals: [], series: [], pages: [row("/_emdash"), row("/about")], refs: [], geo: [] }], + }, + }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.value.topPaths.map((p) => p.path)).toEqual(["/about/"]); + }); + + it("labels the empty referrer instead of rendering a blank row", async () => { + const { fetch } = recorder({ + data: { + viewer: { + accounts: [ + { + totals: [], + series: [], + pages: [], + refs: [ + { count: 20, sum: { visits: 20 }, dimensions: { refererHost: "" } }, + { count: 4, sum: { visits: 4 }, dimensions: { refererHost: "www.google.com" } }, + ], + geo: [], + }, + ], + }, + }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value.referrers.map((r) => r.label)).toEqual([DIRECT_REFERRER, "www.google.com"]); + }); + + it("keeps a missing day missing rather than inventing a zero", async () => { + // A quiet day produces no row at all. Filling it with 0 here would + // make "not fetched" and "no views" indistinguishable downstream. + const { fetch } = recorder({ + data: { + viewer: { + accounts: [ + { + totals: [], + series: [ + { count: 7, sum: { visits: 3 }, avg: { sampleInterval: 1 }, dimensions: { date: "2026-09-18" } }, + { count: 4, sum: { visits: 3 }, avg: { sampleInterval: 1 }, dimensions: { date: "2026-09-20" } }, + ], + pages: [], + refs: [], + geo: [], + }, + ], + }, + }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value.series.map((d) => d.date)).toEqual(["2026-09-18", "2026-09-20"]); + }); + + it("defaults a missing sampling factor to exact", async () => { + const { fetch } = recorder({ + data: { viewer: { accounts: [{ totals: [{ count: 5, sum: { visits: 5 } }], series: [], pages: [], refs: [], geo: [] }] } }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value.totals.sampleInterval).toBe(1); + }); + + it("carries the sampling factor through when the data is estimated", async () => { + const { fetch } = recorder({ + data: { + viewer: { + accounts: [{ totals: [{ count: 14, sum: { visits: 14 }, avg: { sampleInterval: 2.33 } }], series: [], pages: [], refs: [], geo: [] }], + }, + }, + }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value.totals.sampleInterval).toBeCloseTo(2.33); + }); + + it("survives every branch of the response going missing", async () => { + for (const payload of [ + {}, + { data: {} }, + { data: { viewer: {} } }, + { data: { viewer: { accounts: [] } } }, + { data: { viewer: { accounts: [{}] } } }, + { data: { viewer: { accounts: [{ totals: "nonsense", pages: 42 }] } } }, + ]) { + const { fetch } = recorder(payload); + const res = await provider(fetch).overview(RANGE); + // Either a clean error or empty numbers, never a thrown exception. + expect(typeof res.ok).toBe("boolean"); + } + }); +}); + +describe("error mapping", () => { + it("explains a 403 in terms of the permission to fix", async () => { + const { fetch } = recorder({}, 403); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/Account Analytics/); + }); + + it("names the rate limit on a 429", async () => { + const { fetch } = recorder({}, 429); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/429/); + }); + + it("turns the Zone-versus-Account trap into one sentence", async () => { + // A token with Zone Analytics Read answers viewer { zones } happily + // and fails every account query with this message. Without the + // branch the operator sees "GraphQL: not authorized for that + // account" and checks the account ID, which is correct. + const { fetch } = recorder({ data: null, errors: [{ message: "not authorized for that account" }] }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.error).toMatch(/Zone Analytics Read/); + expect(res.error).toMatch(/account-scoped/); + } + }); + + it("passes an unrecognised GraphQL error through verbatim", async () => { + const { fetch } = recorder({ errors: [{ message: "something new" }] }); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toBe("GraphQL: something new"); + }); + + it("reports a non-JSON body instead of throwing", async () => { + const { fetch } = recorder("maintenance"); + const res = await provider(fetch).overview(RANGE); + expect(res.ok).toBe(false); + if (!res.ok) expect(res.error).toMatch(/not JSON/); + }); + + it("turns a transport failure into a value", async () => { + const fetch: FetchLike = async () => { + throw new Error("network down"); + }; + const res = await provider(fetch).overview(RANGE); + expect(res).toMatchObject({ ok: false, problem: { key: "cfUnreachable", params: { detail: "network down" } } }); + if (!res.ok) expect(res.error).toContain("network down"); + }); +}); + +describe("site discovery", () => { + it("groups hosts under their tag without needing Account Settings Read", async () => { + const { calls, fetch } = recorder({ + data: { + viewer: { + accounts: [ + { + [RUM_DATASET]: [ + { count: 80, dimensions: { siteTag: "tag-a", requestHost: "example.com" } }, + { count: 10, dimensions: { siteTag: "tag-a", requestHost: "www.example.com" } }, + { count: 5, dimensions: { siteTag: "tag-b", requestHost: "other.example" } }, + ], + }, + ], + }, + }, + }); + const res = await provider(fetch).discoverSites({ since: "2026-08-21", until: "2026-09-20" }); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.value).toHaveLength(2); + expect(res.value[0]).toMatchObject({ siteTag: "tag-a", pageviews: 90 }); + // Discovery is a plain analytics query, not the site-management API. + expect(calls[0]!.query).toContain(RUM_DATASET); + }); +}); + +describe("retention", () => { + it("converts the API's seconds into the days the plugin reasons in", async () => { + // The live values on 2026-09-20. + const { fetch } = recorder({ + data: { + viewer: { + accounts: [{ settings: { [RUM_DATASET]: { notOlderThan: 15897600, maxDuration: 8035200, maxPageSize: 10000 } } }], + }, + }, + }); + const res = await provider(fetch).retention(); + expect(res.ok).toBe(true); + if (res.ok) expect(res.value).toEqual({ notOlderThanDays: 184, maxDurationDays: 93, maxPageSize: 10000 }); + }); + + it("errors rather than guessing when the probe comes back empty", async () => { + const { fetch } = recorder({ data: { viewer: { accounts: [{ settings: {} }] } } }); + const res = await provider(fetch).retention(); + expect(res.ok).toBe(false); + }); +}); + +describe("the dashboard link", () => { + it("opens the site's own Web Analytics view", () => { + const { fetch } = recorder(EMPTY_OK); + expect(provider(fetch, { accountId: "acct-1", siteTag: "tag-1" }).dashboardUrl?.()).toBe( + "https://dash.cloudflare.com/acct-1/web-analytics/overview?siteTag~in=tag-1", + ); + }); + + it("falls back to the account's site list without a tag, and to nothing without an account", () => { + expect(cloudflareDashboardUrl("acct-1", "")).toBe("https://dash.cloudflare.com/acct-1/web-analytics/sites"); + expect(cloudflareDashboardUrl("", "tag-1")).toBeNull(); + }); +}); diff --git a/packages/analytics/tests/content-sync.test.ts b/packages/analytics/tests/content-sync.test.ts new file mode 100644 index 0000000..fc6241f --- /dev/null +++ b/packages/analytics/tests/content-sync.test.ts @@ -0,0 +1,240 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EntryRow } from "../src/store/rows.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { CONTENT_PATH, REBUILD_ACTION, viewAction, DEFAULT_VIEW } from "../src/ui/content.js"; +import { bridgeCalls } from "./bridge-calls.js"; +import { newHost, NOW, pathsOf, routableCollection, setState, synced } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +const LIMIT = 10; + +type AnyBlock = Record & { type: string }; + +function find(blocks: unknown, type: string): AnyBlock[] { + const out: AnyBlock[] = []; + const walk = (value: unknown) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const record = value as AnyBlock; + if (record.type === type) out.push(record); + Object.values(record).forEach(walk); + } + }; + walk(blocks); + return out; +} + +function tableOf(response: { blocks?: unknown }) { + const table = find(response.blocks, "table")[0]; + if (!table) throw new Error("no table"); + return { + actionId: String(table.page_action_id), + next: table.next_cursor as string | undefined, + rows: table.rows as Array>, + }; +} + +async function put(runtime: PluginRuntimeTestHost, path: string, over: Partial = {}) { + await runtime.fixtures.plugin.storage("entries", path, { + path, + collection: "posts", + entryId: `id${path}`, + translationGroup: `id${path}`, + locale: "en", + title: path, + status: "published", + views7: 0, + views30: 0, + updatedAt: NOW.toISOString(), + ...over, + }); +} + +const load = (runtime: PluginRuntimeTestHost) => runtime.admin.loadPage(CONTENT_PATH); +const act = (runtime: PluginRuntimeTestHost, actionId: string, value?: unknown) => + runtime.admin.act(CONTENT_PATH, actionId, value === undefined ? {} : { value }); + +describe("the per-entry page", () => { + it("lists every published entry, the unviewed ones last, and leaves out unpublished ones", async () => { + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/a/", { views30: 9 }); + await put(host, "/b/", { views30: 0 }); + await put(host, "/c/", { views30: 4 }); + await put(host, "/gone/", { views30: 50, status: "unpublished" }); + await setState(host, synced); + + const { rows } = tableOf(await load(host)); + + expect(rows.map((r) => [r.path, r.views30])).toEqual([ + ["/a/", 9], + ["/c/", 4], + ["/b/", 0], + ]); + }); + + it("sorts on a header click, in storage", async () => { + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/a/", { views7: 1, views30: 9 }); + await put(host, "/b/", { views7: 7, views30: 7 }); + await setState(host, synced); + + const first = tableOf(await load(host)); + const sorted = tableOf(await act(host, first.actionId, { sort: { key: "views7", dir: "desc" } })); + + expect(sorted.rows.map((r) => r.path)).toEqual(["/b/", "/a/"]); + }); + + it("pages through more entries than one table holds", async () => { + host = await newHost(); + await routableCollection(host, 0); + const paths = pathsOf(60); + for (const [i, path] of paths.entries()) await put(host, path, { views30: 100 - i }); + // Kept for history, not shown, and not counted in "Entries 51 to 60". + await put(host, "/hidden/", { views30: 99, status: "unpublished" }); + await setState(host, synced); + + const first = tableOf(await load(host)); + expect(first.rows).toHaveLength(49); + expect(first.next).toBeTruthy(); + + const second = await act(host, first.actionId, { cursor: first.next, sort: null }); + const rest = tableOf(second); + expect(rest.rows.map((r) => r.path)).toEqual(paths.slice(49)); + expect(rest.next).toBeUndefined(); + expect(find(second.blocks, "context").map((b) => b.text).join(" ")).toContain("Entries 50 to 60."); + }); + + it("narrows to one collection", async () => { + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/blog/a/", { views30: 1 }); + await put(host, "/about/", { collection: "pages", views30: 5 }); + await setState(host, synced); + + const { rows } = tableOf( + await act(host, viewAction({ ...DEFAULT_VIEW, collection: "posts" })), + ); + + expect(rows.map((r) => r.path)).toEqual(["/blog/a/"]); + }); + + it("treats a site with one content language as monolingual, whatever the site locale", async () => { + // The host reports "en"; every entry here is German and untranslated. + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/", { locale: "de", views30: 3 }); + await put(host, "/kontakt/", { locale: "de", views30: 1 }); + await setState(host, synced); + + const response = await load(host); + + const columns = (find(response.blocks, "table")[0]!.columns as Array<{ key: string }>).map((c) => c.key); + expect(columns).not.toContain("language"); + expect(find(response.blocks, "button").map((b) => b.label)).not.toContain("Languages combined"); + }); + + it("totals each entry's translations, and combines them on request", async () => { + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/de/a/", { locale: "de", translationGroup: "a", views30: 30, views7: 3 }); + await put(host, "/en/a/", { locale: "en", translationGroup: "a", views30: 12, views7: 1 }); + await put(host, "/en/b/", { locale: "en", translationGroup: "b", views30: 35, views7: 9 }); + await setState(host, synced); + + const each = tableOf(await load(host)); + expect(each.rows.map((r) => [r.path, r.views30, r.allLanguages])).toEqual([ + ["/en/b/", 35, 35], + ["/de/a/", 30, 42], + ["/en/a/", 12, 42], + ]); + + const combined = tableOf(await act(host, viewAction({ ...DEFAULT_VIEW, mode: "combined" }))); + expect(combined.rows.map((r) => [r.views30, r.byLanguage])).toEqual([ + [42, "de 30 · en 12"], + [35, "en 35"], + ]); + }); + + it("stores the collection labels for the analytics page, once", async () => { + host = await newHost(); + await routableCollection(host, 0); + await put(host, "/a/", { views30: 1 }); + await setState(host, synced); + + const first = await bridgeCalls(() => load(host!)); + expect((await host.inspect.kv.get("state"))?.collectionLabels).toEqual({ posts: "Posts" }); + expect(first).toContain("kvSet"); + + // Unchanged labels cost nothing on the next load. + const second = await bridgeCalls(() => load(host!)); + expect(second).not.toContain("kvSet"); + }); + + it("asks for a rebuild and says so", async () => { + host = await newHost(); + await routableCollection(host, 0); + await setState(host, synced); + + const response = await act(host, `${REBUILD_ACTION}|`); + + expect(response.toast).toMatchObject({ type: "success" }); + expect((await host.inspect.kv.get("state"))?.rebuild).toBe(true); + expect(find(response.blocks, "context").map((b) => b.text).join(" ")).toContain("Rebuilding the content index"); + }); +}); + +describe("the per-entry page's bridge calls", () => { + async function manyTranslated(runtime: PluginRuntimeTestHost, groups: number) { + await routableCollection(runtime, 0); + for (let i = 0; i < groups; i++) { + for (const locale of ["de", "en", "fr"]) { + const path = `/${locale}/p-${String(i).padStart(3, "0")}/`; + await put(runtime, path, { locale, translationGroup: `g${i}`, views30: i, views7: i }); + } + } + await runtime.fixtures.plugin.storage("daily", `2026-09-01|/de/p-000/`, { + date: "2026-09-01", + path: "/de/p-000/", + pageviews: 1, + visits: 1, + sampleInterval: 1, + fetchedAt: NOW.toISOString(), + }); + await setState(runtime, synced); + } + + it("a load in each-language mode, translations totalled", async () => { + host = await newHost(); + await manyTranslated(host, 40); + const calls = await bridgeCalls(() => load(host!)); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(calls.filter((c) => c === "storageQuery").length).toBeGreaterThanOrEqual(3); + }); + + it("a load in combined mode with more entries than it reads", async () => { + host = await newHost(); + await manyTranslated(host, 110); + const calls = await bridgeCalls(() => act(host!, viewAction({ ...DEFAULT_VIEW, mode: "combined" }))); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(calls.filter((c) => c === "storageQuery").length).toBeGreaterThanOrEqual(4); + }); + + it("a rebuild request", async () => { + host = await newHost(); + await manyTranslated(host, 40); + const calls = await bridgeCalls(() => act(host!, `${REBUILD_ACTION}|`)); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(LIMIT); + expect(calls).toContain("kvSet"); + }); +}); diff --git a/packages/analytics/tests/content.test.ts b/packages/analytics/tests/content.test.ts new file mode 100644 index 0000000..4d24db0 --- /dev/null +++ b/packages/analytics/tests/content.test.ts @@ -0,0 +1,236 @@ +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import { describe, expect, it } from "vitest"; + +import type { EntryRow } from "../src/store/rows.js"; +import { + combine, + CONTENT_PATH, + DEFAULT_VIEW, + parseContentInput, + renderContent, + REBUILD_ACTION, + TABLE_ACTION, + viewAction, + type ContentInput, + type ContentView, +} from "../src/ui/content.js"; +import { PAGE_PATH } from "../src/ui/page.js"; + +const NOW = new Date("2026-09-24T12:00:00.000Z"); + +const row = (path: string, over: Partial = {}): EntryRow => ({ + path, + collection: "posts", + entryId: `id${path}`, + translationGroup: `id${path}`, + locale: "en", + title: path, + status: "published", + views7: 0, + views30: 0, + updatedAt: NOW.toISOString(), + ...over, +}); + +function input(over: Partial = {}): ContentInput { + return { + view: DEFAULT_VIEW, + state: { phase: "overview", provider: "cloudflare", indexComplete: true, indexVersion: 2, lastSync: NOW.toISOString() }, + collections: [ + { slug: "pages", label: "Pages" }, + { slug: "posts", label: "Posts" }, + ], + rows: [ + { title: "Hello", collection: "posts", locale: "en", path: "/blog/hello/", views7: 5, views30: 20, allLanguages: 32 }, + { title: "", collection: "pages", locale: "en", path: "/quiet/", views7: 0, views30: 0, allLanguages: 0 }, + ], + multilingual: false, + now: NOW, + locale: "en", + ...over, + }; +} + +function expectValid(blocks: unknown) { + const result = validateBlockResponse({ blocks }, { pluginPagePaths: [PAGE_PATH, CONTENT_PATH] }); + if (!result.valid) { + throw new Error(`Block Kit validation failed:\n${result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`); + } +} + +type AnyBlock = Record & { type: string }; + +function find(blocks: unknown[], type: string): AnyBlock[] { + const out: AnyBlock[] = []; + const walk = (value: unknown) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const record = value as AnyBlock; + if (record.type === type) out.push(record); + Object.values(record).forEach(walk); + } + }; + walk(blocks); + return out; +} + +const texts = (blocks: unknown[]) => find(blocks, "context").map((b) => String(b.text)); + +describe("the view travels in the action ids", () => { + const view: ContentView = { collection: "posts", sort: "views7", dir: "asc", mode: "combined", offset: 0 }; + + it("round-trips through a view button", () => { + expect(parseContentInput({ type: "block_action", action_id: viewAction(view) })).toEqual({ view, rebuild: false }); + }); + + it("takes a header click's sort and starts from the top", () => { + const table = find(renderContent(input({ view: { ...view, offset: 50, cursor: "c1" } })), "table")[0]!; + const parsed = parseContentInput({ + type: "block_action", + action_id: table.page_action_id, + value: { sort: { key: "views30", dir: "desc" } }, + }); + expect(parsed.view).toEqual({ ...view, sort: "views30", dir: "desc", offset: 0 }); + }); + + it("keeps the sort from the id when Load more sends none", () => { + const table = find(renderContent(input({ view, nextCursor: "50~abc" })), "table")[0]!; + expect(table.next_cursor).toBe("50~abc"); + const parsed = parseContentInput({ + type: "block_action", + action_id: table.page_action_id, + value: { cursor: table.next_cursor, sort: null }, + }); + expect(parsed.view).toEqual({ ...view, offset: 50, cursor: "abc" }); + }); + + it("asks for a rebuild without losing the view", () => { + const blocks = renderContent(input({ view })); + const rebuild = find(blocks, "button").find((b) => String(b.action_id).startsWith(REBUILD_ACTION))!; + expect(parseContentInput({ type: "block_action", action_id: rebuild.action_id })).toEqual({ view, rebuild: true }); + }); + + it("falls back to the default view on anything it does not know", () => { + expect(parseContentInput({ type: "page_load", page: CONTENT_PATH }).view).toEqual(DEFAULT_VIEW); + expect(parseContentInput({ type: "block_action", action_id: `${TABLE_ACTION}|%zz|nope` }).view).toMatchObject({ + collection: null, + sort: "views30", + }); + expect(parseContentInput({ type: "block_action", action_id: `${VIEW_ACTION_ALIAS}|x` }).view).toEqual(DEFAULT_VIEW); + }); +}); + +const VIEW_ACTION_ALIAS = "analytics:something-else"; + +describe("renderContent", () => { + it("renders valid blocks with every entry, the unviewed ones included", () => { + const blocks = renderContent(input()); + expectValid(blocks); + const [table] = find(blocks, "table"); + expect((table!.rows as Array>).map((r) => r.path)).toEqual(["/blog/hello/", "/quiet/"]); + expect((table!.rows as Array>)[1]).toMatchObject({ entry: "—", views30: 0 }); + }); + + it("shows languages and translation totals only on a multilingual site", () => { + const keys = (i: ContentInput) => + (find(renderContent(i), "table")[0]!.columns as Array<{ key: string }>).map((c) => c.key); + expect(keys(input())).not.toContain("allLanguages"); + expect(keys(input({ multilingual: true }))).toEqual(expect.arrayContaining(["language", "allLanguages"])); + const modes = find(renderContent(input({ multilingual: true })), "button").map((b) => b.label); + expect(modes).toEqual(expect.arrayContaining(["Each language", "Languages combined"])); + }); + + it("hides the collection column once a collection is chosen", () => { + const columns = find(renderContent(input({ view: { ...DEFAULT_VIEW, collection: "posts" } })), "table")[0]! + .columns as Array<{ key: string }>; + expect(columns.map((c) => c.key)).not.toContain("collection"); + }); + + it("marks the chosen collection", () => { + const buttons = find(renderContent(input({ view: { ...DEFAULT_VIEW, collection: "posts" } })), "actions")[0]! + .elements as Array<{ label: string; style: string }>; + expect(buttons.map((b) => [b.label, b.style])).toEqual([ + ["All", "secondary"], + ["Pages", "secondary"], + ["Posts", "primary"], + ]); + }); + + it("says where 30-day figures start when the store is younger than 30 days", () => { + expect(texts(renderContent(input({ historySince: "2026-09-16" }))).join(" ")).toContain("30-day figures count from"); + expect(texts(renderContent(input({ historySince: "2026-08-01" }))).join(" ")).not.toContain("30-day figures"); + }); + + it("says which rows a later page shows, and offers the first page", () => { + const blocks = renderContent(input({ view: { ...DEFAULT_VIEW, offset: 50, cursor: "c" } })); + expect(texts(blocks).join(" ")).toContain("Entries 51 to 52."); + expect(find(blocks, "button").map((b) => b.label)).toContain("First page"); + }); + + it("explains an empty index instead of showing an empty table", () => { + const building = renderContent(input({ rows: [], state: { phase: "overview", indexComplete: false } })); + expectValid(building); + expect(find(building, "empty")[0]).toMatchObject({ title: "No entries yet" }); + expect(find(building, "empty")[0]!.description).toContain("still being built"); + + const noUrls = renderContent(input({ rows: [] })); + expect(find(noUrls, "empty")[0]!.description).toContain("site URL"); + }); + + it("writes publish dates in the reader's language", () => { + const blocks = renderContent( + input({ + locale: "de", + rows: [{ title: "Hallo", collection: "posts", locale: "de", path: "/", views7: 1, views30: 1, publishedAt: "2026-09-18T08:00:00.000Z" }], + }), + ); + expect((find(blocks, "table")[0]!.rows as Array>)[0]!.published).toBe("18.09.2026"); + }); + + it("links back to the overview", () => { + const links = find(renderContent(input()), "link"); + expect(links.map((l) => l.target)).toContainEqual({ kind: "plugin-page", path: PAGE_PATH }); + }); +}); + +describe("combine", () => { + const view: ContentView = { ...DEFAULT_VIEW, mode: "combined" }; + + it("adds up translations and sorts by the group total", () => { + const rows = [ + row("/de/a/", { locale: "de", translationGroup: "a", views30: 30, views7: 3 }), + row("/en/a/", { locale: "en", translationGroup: "a", views30: 12, views7: 1 }), + row("/de/b/", { locale: "de", translationGroup: "b", views30: 35, views7: 9 }), + ]; + const out = combine(rows, view, "de"); + expect(out.map((r) => [r.path, r.views30, r.views7])).toEqual([ + ["/de/a/", 42, 4], + ["/de/b/", 35, 9], + ]); + expect(out[0]!.byLanguage).toBe("de 30 · en 12"); + }); + + it("names a group after its original, whatever the site locale says", () => { + // As on eisbachcode.de: German originals, English translations, and a + // site locale that does not read "de". + const rows = [ + row("/en/", { locale: "en", entryId: "t", translationGroup: "o", title: "Home", views30: 50 }), + row("/", { locale: "de", entryId: "o", translationGroup: "o", title: "Startseite", views30: 1 }), + ]; + expect(combine(rows, view, "en")[0]).toMatchObject({ title: "Startseite", path: "/" }); + }); + + it("falls back to the site-locale version, then the most viewed, when the original is not among the rows", () => { + const rows = [ + row("/en/a/", { locale: "en", entryId: "x", translationGroup: "a", title: "Hello", views30: 50 }), + row("/de/a/", { locale: "de", entryId: "y", translationGroup: "a", title: "Hallo", views30: 1 }), + ]; + expect(combine(rows, view, "de")[0]).toMatchObject({ title: "Hallo" }); + expect(combine(rows, view, "fr")[0]).toMatchObject({ title: "Hello" }); + }); + + it("sorts ascending when asked, so groups nobody reads come first", () => { + const rows = [row("/a/", { views30: 5 }), row("/b/", { views30: 0 })]; + expect(combine(rows, { ...view, dir: "asc" }, "en").map((r) => r.path)).toEqual(["/b/", "/a/"]); + }); +}); diff --git a/packages/analytics/tests/demo-sync.test.ts b/packages/analytics/tests/demo-sync.test.ts new file mode 100644 index 0000000..2759edd --- /dev/null +++ b/packages/analytics/tests/demo-sync.test.ts @@ -0,0 +1,124 @@ +import { validateBlocks, type StatsBlock, type TableBlock } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { addDays, utcDay } from "../src/sync/window.js"; + +/** + * Demo data through the real sandbox, bridge and storage. + * + * Plugin storage outlives `dispose()` within a file, so every assertion + * names the row it is about. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +async function demoHost() { + const runtime = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + await runtime.fixtures.plugin.setting("provider", "demo"); + return runtime; +} + +/** Click Refresh, then let the scheduler run the tick it asked for. */ +async function refreshAndRun(runtime: PluginRuntimeTestHost) { + const response = await runtime.admin.act("widget:traffic", "analytics:refresh"); + runtime.scheduled.setTime(new Date()); + await runtime.scheduled.run(); + return response; +} + +function tick(runtime: PluginRuntimeTestHost) { + return runtime.transport.invokeHook("cron", { name: "sync", scheduledAt: new Date().toISOString() }); +} + +describe("demo data", () => { + it("fills 90 days of history on the first refresh, and says it is demo data", async () => { + host = await demoHost(); + const today = utcDay(new Date()); + + const response = await refreshAndRun(host); + expect(response.toast).toMatchObject({ type: "success" }); + + await expect(host.inspect.storage.get("rollup", addDays(today, -90))).resolves.toMatchObject({ + date: addDays(today, -90), + }); + + const widget = await host.admin.loadWidget("traffic"); + expect(validateBlocks(widget.blocks).valid).toBe(true); + expect(JSON.stringify(widget.blocks)).toMatch(/Demo data, not real traffic/); + }); + + it("counts the widget's page table over the same days as its cards", async () => { + // With three demo paths the table lists every page, so its views + // add up to the page-view card, unless the two cover different days. + host = await demoHost(); + // Overview with backfill, indexing, then an overview on the regular window. + for (let i = 0; i < 3; i++) await tick(host); + + const { blocks } = await host.admin.loadWidget("traffic"); + const cards = blocks.find((b): b is StatsBlock => b.type === "stats"); + const pages = blocks.find((b): b is TableBlock => b.type === "table"); + const pageviews = Number(String(cards?.items[1]?.value).replace(/\D/g, "")); + + expect(pages?.rows).toHaveLength(3); + expect(pages?.rows.reduce((n, row) => n + Number(row.views), 0)).toBe(pageviews); + }); + + it("gets through a paths phase that reads more ids than D1 binds in one statement", async () => { + // 13 entries x 8 days is 104 daily ids. Storage binds the plugin id + // and collection besides the ids, and D1 stops at 100 parameters, so + // chunks of 100 failed with "too many SQL variables" on the first + // paths phase of any site with 13 or more entries. + host = await demoHost(); + await host.fixtures.collection({ + slug: "posts", + label: "Posts", + urlPattern: "/blog/{slug}", + routable: true, + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + for (let i = 0; i < 13; i++) { + await host.fixtures.content("posts", { + slug: `post-${i}`, + data: { title: `Post ${i}` }, + status: "published", + publishedAt: new Date().toISOString(), + }); + } + + const today = utcDay(new Date()); + let pathsRan = false; + for (let i = 0; i < 12 && !pathsRan; i++) { + await tick(host); + pathsRan = (await host.inspect.storage.get("daily", `${addDays(today, -1)}|/blog/post-0/`)) !== null; + } + expect(pathsRan).toBe(true); + }); + + it("clears the generated numbers when the source is switched back to Cloudflare", async () => { + // Left in place, demo numbers would render as if they were the + // site's real traffic the moment Cloudflare was configured. + host = await demoHost(); + const today = utcDay(new Date()); + await refreshAndRun(host); + await expect(host.inspect.storage.get("rollup", today)).resolves.not.toBeNull(); + + await host.fixtures.plugin.setting("provider", "cloudflare"); + await tick(host); + + await expect(host.inspect.storage.get("rollup", today)).resolves.toBeNull(); + await expect(host.inspect.storage.get("rollup", addDays(today, -90))).resolves.toBeNull(); + + const widget = await host.admin.loadWidget("traffic"); + const text = JSON.stringify(widget.blocks); + expect(text).not.toMatch(/Demo data/); + expect(text).toMatch(/No analytics yet/); + }); +}); diff --git a/packages/analytics/tests/demo.test.ts b/packages/analytics/tests/demo.test.ts new file mode 100644 index 0000000..c70c312 --- /dev/null +++ b/packages/analytics/tests/demo.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { createDemoProvider } from "../src/providers/demo.js"; +import { addDays, enumerateDays } from "../src/sync/window.js"; + +const NOW = new Date("2026-09-23T12:00:00.000Z"); +const TODAY = "2026-09-23"; +const ENTRY_PATHS = ["/", "/about/", "/blog/first-post/", "/blog/second-post/", "/contact/"]; + +function demo(paths: string[] = ENTRY_PATHS) { + return createDemoProvider({ listPaths: async () => paths, now: () => NOW, trailingSlash: "always" }); +} + +async function overviewOf(range: { since: string; until: string }, paths?: string[]) { + const res = await demo(paths).overview(range); + if (!res.ok) throw new Error(res.error); + return res.value; +} + +describe("the demo provider", () => { + it("returns the same numbers for a closed day on every tick", async () => { + // Read-compare-write only skips rows whose numbers did not change. A + // generator that drifted between ticks would rewrite every stored row + // every 15 minutes, which is the D1 write budget the design protects. + const range = { since: addDays(TODAY, -10), until: addDays(TODAY, -1) }; + const first = await overviewOf(range); + const second = await overviewOf(range); + expect(second.series).toEqual(first.series); + expect(second.topPaths).toEqual(first.topPaths); + }); + + it("agrees with itself between the overview and the per-path numbers", async () => { + // The content report joins per-path rows to entries while the widget + // shows the overview's top pages; if the two disagreed, the demo + // would show two different numbers for one page. + const range = { since: addDays(TODAY, -6), until: TODAY }; + const overview = await overviewOf(range); + const res = await demo().paths(ENTRY_PATHS, range); + if (!res.ok) throw new Error(res.error); + + for (const top of overview.topPaths.filter((p) => ENTRY_PATHS.includes(p.path))) { + const rows = res.value.filter((r) => r.path === top.path); + expect(rows.reduce((n, r) => n + r.pageviews, 0)).toBe(top.pageviews); + expect(rows.reduce((n, r) => n + r.visits, 0)).toBe(top.visits); + } + }); + + it("adds its daily series up to its totals", async () => { + const overview = await overviewOf({ since: addDays(TODAY, -29), until: TODAY }); + expect(overview.series).toHaveLength(30); + expect(overview.series.reduce((n, d) => n + d.pageviews, 0)).toBe(overview.totals.pageviews); + expect(overview.series.reduce((n, d) => n + d.visits, 0)).toBe(overview.totals.visits); + }); + + it("has quieter weekends than weekdays, like a business site", async () => { + const overview = await overviewOf({ since: "2026-08-03", until: "2026-08-30" }); + const byWeekday = (days: number[]) => + overview.series.filter((d) => days.includes(new Date(`${d.date}T00:00:00Z`).getUTCDay())); + const mean = (rows: Array<{ pageviews: number }>) => rows.reduce((n, r) => n + r.pageviews, 0) / rows.length; + expect(mean(byWeekday([0, 6]))).toBeLessThan(mean(byWeekday([2, 3, 4]))); + }); + + it("marks today as sampled and provisional, and closed days as exact", async () => { + // Mirrors Cloudflare, so the demo shows the same labels a real + // install does. + const overview = await overviewOf({ since: addDays(TODAY, -3), until: TODAY }); + const today = overview.series.find((d) => d.date === TODAY)!; + const closed = overview.series.filter((d) => d.date !== TODAY); + expect(today.sampleInterval).toBeGreaterThan(1); + for (const day of closed) expect(day.sampleInterval).toBe(1); + + // Half the day has passed at NOW, so today is well below a closed day. + const yesterday = overview.series.find((d) => d.date === addDays(TODAY, -1))!; + expect(today.pageviews).toBeLessThan(yesterday.pageviews); + }); + + it("puts some traffic on paths that are no entry, as real sites have", async () => { + const overview = await overviewOf({ since: addDays(TODAY, -6), until: TODAY }); + expect(overview.topPaths.some((p) => !ENTRY_PATHS.includes(p.path))).toBe(true); + expect(overview.topPaths[0]!.path).toBe("/"); + }); + + it("still produces numbers before any content is indexed", async () => { + const overview = await overviewOf({ since: addDays(TODAY, -6), until: TODAY }, []); + expect(overview.totals.pageviews).toBeGreaterThan(0); + }); + + it("answers for a 90-day window, which the first sync backfills", async () => { + const range = { since: addDays(TODAY, -90), until: TODAY }; + const overview = await overviewOf(range); + expect(overview.series.map((d) => d.date)).toEqual(enumerateDays(range.since, range.until)); + }); + + it("reports nothing for a path it was never told about", async () => { + const res = await demo().paths(["/not-an-entry/"], { since: addDays(TODAY, -6), until: TODAY }); + expect(res).toEqual({ ok: true, value: [] }); + }); + + it("labels referrers and countries the way Cloudflare does", async () => { + const overview = await overviewOf({ since: addDays(TODAY, -6), until: TODAY }); + expect(overview.referrers.map((r) => r.label)).toContain("(direct)"); + for (const c of overview.countries) expect(c.label).toMatch(/^[A-Z]{2}$/); + }); +}); diff --git a/packages/analytics/tests/errors.test.ts b/packages/analytics/tests/errors.test.ts new file mode 100644 index 0000000..74cfb42 --- /dev/null +++ b/packages/analytics/tests/errors.test.ts @@ -0,0 +1,74 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { INDEX_VERSION } from "../src/index/bootstrap.js"; +import { GRAPHQL_ENDPOINT, RUM_DATASET } from "../src/providers/cloudflare.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { utcDay } from "../src/sync/window.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { headers: { "Content-Type": "application/json" } }); +} + +const OVERVIEW = { data: { viewer: { accounts: [{ totals: [], series: [], pages: [], refs: [], geo: [] }] } } }; +const PATHS = { data: { viewer: { accounts: [{ [RUM_DATASET]: [] }] } } }; + +async function tick(runtime: PluginRuntimeTestHost, name = "sync") { + await runtime.transport.invokeHook("cron", { name, scheduledAt: new Date().toISOString() }); + return await runtime.inspect.kv.get("state"); +} + +describe("a failed tick", () => { + it("stays visible through a Refresh until the phase that failed succeeds", async () => { + // A Refresh runs an overview out of turn; clearing a paths error there + // would hide it until the next paths tick fails again. + vi.stubEnv("EMDASH_ENCRYPTION_KEY", "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + host = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + await host.actions.plugin.updateSettings({ cfApiToken: "cfat_test", cfAccountId: "acct-1", cfSiteTag: "tag-1" }); + await host.fixtures.plugin.storage("entries", "/about/", { + path: "/about/", + collection: "pages", + entryId: "e1", + translationGroup: "e1", + locale: "en", + title: "About", + status: "published", + views7: 0, + views30: 0, + updatedAt: new Date().toISOString(), + }); + await host.fixtures.plugin.kv("state", { + phase: "paths", + provider: "cloudflare", + backfilled: true, + indexComplete: true, + indexVersion: INDEX_VERSION, + older: { day: utcDay(new Date()), complete: true }, + lastSync: new Date().toISOString(), + }); + + await host.http.respond(GRAPHQL_ENDPOINT, new Response("upstream down", { status: 502 })); + await host.http.respond(GRAPHQL_ENDPOINT, json(OVERVIEW)); + await host.http.respond(GRAPHQL_ENDPOINT, json(PATHS)); + + const failed = await tick(host); + expect(failed?.lastProblem?.key).toBe("cfHttp"); + + const afterRefresh = await tick(host, "refresh"); + expect(afterRefresh?.phase).toBe("paths"); + expect(afterRefresh?.lastProblem?.key).toBe("cfHttp"); + + const afterPaths = await tick(host); + expect(afterPaths?.lastError).toBeUndefined(); + }); +}); diff --git a/packages/analytics/tests/host.ts b/packages/analytics/tests/host.ts new file mode 100644 index 0000000..ccaa18b --- /dev/null +++ b/packages/analytics/tests/host.ts @@ -0,0 +1,135 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { expect, vi } from "vitest"; + +import { INDEX_VERSION } from "../src/index/bootstrap.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { addDays, utcDay } from "../src/sync/window.js"; + +/** + * Fixtures for tests that run the plugin inside the runtime test host. + * + * The host itself is created and disposed by each test file, because + * disposal belongs in that file's `afterEach`. + */ + +export const NOW = new Date(); +export const TODAY = utcDay(NOW); + +export async function newHost(provider: "demo" | "cloudflare" = "demo") { + if (provider === "cloudflare") { + vi.stubEnv("EMDASH_ENCRYPTION_KEY", "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + } + const runtime = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + if (provider === "cloudflare") { + const saved = await runtime.actions.plugin.updateSettings({ + cfApiToken: "cfat_test", + cfAccountId: "acct-1", + cfSiteTag: "tag-1", + }); + expect(saved).toMatchObject({ success: true }); + } else { + await runtime.fixtures.plugin.setting("provider", "demo"); + } + return runtime; +} + +export async function setState(runtime: PluginRuntimeTestHost, state: SyncState) { + await runtime.fixtures.plugin.kv("state", state); +} + +export async function seedEntries(runtime: PluginRuntimeTestHost, paths: string[], views = 0) { + for (const path of paths) { + await runtime.fixtures.plugin.storage("entries", path, { + path, + collection: "posts", + entryId: `id${path}`, + translationGroup: `id${path}`, + locale: "en", + title: path, + status: "published", + views7: views, + views30: views, + updatedAt: NOW.toISOString(), + }); + } +} + +export async function seedRollup(runtime: PluginRuntimeTestHost, days: number) { + for (let i = 0; i < days; i++) { + const date = addDays(TODAY, -i); + await runtime.fixtures.plugin.storage("rollup", date, { + date, + pageviews: 10, + visits: 5, + sampleInterval: 1, + fetchedAt: NOW.toISOString(), + }); + } +} + +export async function seedDaily(runtime: PluginRuntimeTestHost, paths: string[], dates: string[]) { + for (const date of dates) { + for (const path of paths) { + await runtime.fixtures.plugin.storage("daily", `${date}|${path}`, { + date, + path, + pageviews: 3, + visits: 2, + sampleInterval: 1, + fetchedAt: NOW.toISOString(), + }); + } + } +} + +export async function routableCollection(runtime: PluginRuntimeTestHost, published: number, drafts = 0) { + await runtime.fixtures.collection({ + slug: "posts", + label: "Posts", + urlPattern: "/blog/{slug}", + routable: true, + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + const ids: string[] = []; + for (let i = 0; i < published + drafts; i++) { + const draft = i >= published; + const item = await runtime.fixtures.content("posts", { + slug: `post-${i}`, + data: { title: `Post ${i}` }, + status: draft ? "draft" : "published", + ...(draft ? {} : { publishedAt: NOW.toISOString() }), + }); + ids.push(item.id); + } + return ids; +} + +export function pathsOf(count: number, prefix = "/p-"): string[] { + return Array.from({ length: count }, (_, i) => `${prefix}${String(i).padStart(2, "0")}/`); +} + +export function daysBack(count: number): string[] { + return Array.from({ length: count }, (_, i) => addDays(TODAY, -i)); +} + +export function graphql(body: unknown): Response { + return new Response(JSON.stringify({ data: { viewer: { accounts: [body] } } }), { + headers: { "Content-Type": "application/json" }, + }); +} + +export const tick = (runtime: PluginRuntimeTestHost, name = "sync") => () => + runtime.transport.invokeHook("cron", { name, scheduledAt: NOW.toISOString() }); + +/** A site that is fully caught up: index walked by this build, today's older pass done. */ +export const synced: SyncState = { + phase: "overview", + provider: "demo", + backfilled: true, + indexComplete: true, + indexVersion: INDEX_VERSION, + older: { day: TODAY, complete: true }, + lastSync: NOW.toISOString(), +}; diff --git a/packages/analytics/tests/i18n.test.ts b/packages/analytics/tests/i18n.test.ts new file mode 100644 index 0000000..9d83ec0 --- /dev/null +++ b/packages/analytics/tests/i18n.test.ts @@ -0,0 +1,128 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { langOf, t } from "../src/i18n.js"; +import type { RollupRow } from "../src/store/rows.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { renderPage, type PageInput } from "../src/ui/page.js"; +import { renderWidget, type WidgetInput } from "../src/ui/widget.js"; + +const NOW = new Date("2026-09-20T12:00:00.000Z"); + +function days(count: number, pageviews: number): RollupRow[] { + return Array.from({ length: count }, (_, i) => ({ + date: new Date(NOW.getTime() - i * 86_400_000).toISOString().slice(0, 10), + pageviews, + visits: pageviews, + sampleInterval: 1, + fetchedAt: NOW.toISOString(), + })); +} + +function widget(locale: string | undefined, state: SyncState = { phase: "overview", lastSync: NOW.toISOString() }) { + const input: WidgetInput = { state, rollups: days(14, 1234), entriesByPath: new Map(), now: NOW, locale }; + return JSON.stringify(renderWidget(input)); +} + +function page(locale: string | undefined) { + const input: PageInput = { + state: { + phase: "overview", + provider: "cloudflare", + lastSync: NOW.toISOString(), + countries: [{ label: "DE", visits: 3 }], + snapshotSince: "2026-09-18", + }, + range: 7, + rollups: days(14, 1234), + topEntries: { rows: [], partial: false }, + entriesByPath: new Map(), + dashboardUrl: "https://dash.cloudflare.com/acct/web-analytics/overview?siteTag~in=tag", + now: NOW, + locale, + }; + return JSON.stringify(renderPage(input)); +} + +describe("which catalogue a locale reads", () => { + it("reads German for every German admin locale and English for the rest", () => { + expect(["de", "de-DE", "de-AT", "de_CH"].map(langOf)).toEqual(["de", "de", "de", "de"]); + expect(["en", "en-GB", "ar", "fr", undefined].map(langOf)).toEqual(["en", "en", "en", "en", "en"]); + }); + + it("picks the singular and plural forms per language", () => { + expect(t("de", "rangeDays", { count: 1 })).toBe("1 Tag"); + expect(t("de", "rangeDays", { count: 7 })).toBe("7 Tage"); + expect(t("en", "rangeDays", { count: 1 })).toBe("1 day"); + }); +}); + +describe("the widget and the page in German", () => { + it("labels, numbers and the comparison follow the admin locale", () => { + const de = widget("de"); + expect(de).toContain("Besuche, letzte 7 Tage"); + expect(de).toContain("8.638"); + expect(de).toContain("Aktualisieren"); + expect(de).not.toContain("Visits, last"); + + const en = widget("en"); + expect(en).toContain("Visits, last 7 days"); + expect(en).toContain("8,638"); + }); + + it("falls back to English for an admin locale without a catalogue", () => { + expect(widget("ar")).toContain("Visits, last 7 days"); + expect(widget(undefined)).toContain("Visits, last 7 days"); + }); + + it("shows a stored sync failure in the reader's language, not the one it was written in", () => { + // Failures are written by cron ticks, which have no locale at all. + const state: SyncState = { + phase: "overview", + lastSync: NOW.toISOString(), + lastError: "Cloudflare rejected the token (403). It needs Account → Account Analytics → Read.", + lastProblem: { key: "cfForbidden" }, + lastErrorAt: NOW.toISOString(), + }; + expect(widget("de", state)).toContain("Cloudflare hat das Token abgelehnt (403)"); + expect(widget("en", state)).toContain("Cloudflare rejected the token (403)"); + }); + + it("shows an unrecognized failure verbatim in every language", () => { + const state: SyncState = { + phase: "overview", + lastSync: NOW.toISOString(), + lastError: "GraphQL: something new", + lastErrorAt: NOW.toISOString(), + }; + expect(widget("de", state)).toContain("GraphQL: something new"); + }); + + it("names ranges, countries and dates in German on the page", () => { + const de = page("de"); + expect(de).toContain("7 Tage"); + expect(de).toContain("Meistbesuchte Einträge"); + expect(de).toContain("Deutschland"); + expect(de).toContain("In Cloudflare öffnen"); + expect(de).toContain("18.09.2026"); + }); +}); + +describe("through the sandbox", () => { + let host: PluginRuntimeTestHost | undefined; + + afterEach(async () => { + await host?.dispose(); + host = undefined; + }); + + it("answers a German admin in German, the refresh toast included", async () => { + host = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + const response = await host.admin.act("widget:traffic", "analytics:refresh", { locale: "de" }); + expect(response.toast?.type).toBe("error"); + expect(String(response.toast?.message)).toMatch(/noch nicht eingerichtet/); + expect(JSON.stringify(response.blocks)).toMatch(/Aktualisieren/); + }); +}); diff --git a/packages/analytics/tests/page-sync.test.ts b/packages/analytics/tests/page-sync.test.ts new file mode 100644 index 0000000..3c1d6d1 --- /dev/null +++ b/packages/analytics/tests/page-sync.test.ts @@ -0,0 +1,68 @@ +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * The analytics page through the real sandbox, fed by the demo provider. + * Plugin storage outlives `dispose()` within a file; these tests only read + * what the first refresh of each host wrote. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +async function demoHostWithData() { + const runtime = await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); + await runtime.fixtures.plugin.setting("provider", "demo"); + await runtime.admin.act("widget:traffic", "analytics:refresh"); + runtime.scheduled.setTime(new Date()); + await runtime.scheduled.run(); + return runtime; +} + +function chartLengths(response: { blocks: unknown[] }): number[] { + const chart = response.blocks.find((b) => (b as { type: string }).type === "chart") as + | { config: { series: Array<{ data: unknown[] }> } } + | undefined; + return chart ? chart.config.series.map((s) => s.data.length) : []; +} + +describe("the analytics page", () => { + it("is declared, so the admin lists it in the sidebar", async () => { + host = await createPluginRuntimeTestHost(); + const manifest = host.manifest as unknown as { admin?: { pages?: Array<{ path: string; label: string }> } }; + expect(manifest.admin?.pages).toEqual(expect.arrayContaining([expect.objectContaining({ path: "/analytics" })])); + }); + + it("charts 30 days by default and 90 after the range changes", async () => { + host = await demoHostWithData(); + + const first = await host.admin.loadPage("/analytics"); + const pages = (host.manifest.admin?.pages ?? []).map((page) => page.path); + expect(validateBlockResponse(first, { pluginPagePaths: pages }).valid).toBe(true); + expect(chartLengths(first)).toEqual([30, 30]); + + const wider = await host.admin.act("/analytics", "analytics:range", { value: 90 }); + expect(chartLengths(wider)).toEqual([90, 90]); + }); + + it("keeps the range across a refresh, which is the only place Block Kit can keep it", async () => { + host = await demoHostWithData(); + const refreshed = await host.admin.act("/analytics", "analytics:page:refresh", { value: 7 }); + expect(chartLengths(refreshed)).toEqual([7, 7]); + expect(refreshed.toast).toMatchObject({ type: "success" }); + }); + + it("opens for an editor, not only for an admin", async () => { + host = await demoHostWithData(); + const editor = await host.fixtures.user({ email: "editor@example.test", role: "editor" }); + const response = await host.admin.loadPage("/analytics", { user: editor }); + expect(chartLengths(response)).toEqual([30, 30]); + }); +}); diff --git a/packages/analytics/tests/page.test.ts b/packages/analytics/tests/page.test.ts new file mode 100644 index 0000000..2e3611e --- /dev/null +++ b/packages/analytics/tests/page.test.ts @@ -0,0 +1,277 @@ +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import { describe, expect, it } from "vitest"; + +import type { EntryRow, RollupRow } from "../src/store/rows.js"; +import { addDays } from "../src/sync/window.js"; +import { CONTENT_PATH } from "../src/ui/content.js"; +import { formatDay } from "../src/ui/format.js"; +import { + PAGE_PATH, + PAGE_REFRESH_ACTION, + parseRange, + RANGE_ACTION, + mergeDays, + renderPage, + summarizeDaily, + type PageInput, +} from "../src/ui/page.js"; + +const NOW = new Date("2026-09-23T12:00:00.000Z"); +const TODAY = "2026-09-23"; + +const rollup = (date: string, pageviews: number, visits = Math.round(pageviews * 0.7), sampleInterval = 1): RollupRow => ({ + date, + pageviews, + visits, + sampleInterval, + fetchedAt: NOW.toISOString(), +}); + +/** `days` consecutive rollups ending today. */ +function history(days: number, pageviews = 100): RollupRow[] { + return Array.from({ length: days }, (_, i) => rollup(addDays(TODAY, -i), pageviews)); +} + +const entry = (path: string, title: string, collection = "posts"): EntryRow => ({ + path, + collection, + entryId: `id-${path}`, + translationGroup: `id-${path}`, + locale: "en", + title, + status: "published", + views7: 0, + views30: 0, + updatedAt: NOW.toISOString(), +}); + +function input(over: Partial = {}): PageInput { + return { + state: { + phase: "overview", + provider: "cloudflare", + lastSync: "2026-09-23T11:55:00.000Z", + referrers: [{ label: "(direct)", visits: 40 }], + countries: [{ label: "DE", visits: 30 }], + snapshotSince: addDays(TODAY, -2), + }, + range: 30, + rollups: history(60), + topEntries: { + rows: [ + { path: "/", pageviews: 50, visits: 40 }, + { path: "/blog/hello/", pageviews: 20, visits: 15 }, + ], + coveredSince: addDays(TODAY, -29), + partial: false, + }, + entriesByPath: new Map([ + ["/", entry("/", "Home", "home")], + ["/blog/hello/", entry("/blog/hello/", "Hello")], + ]), + dashboardUrl: "https://dash.cloudflare.com/acct/web-analytics/overview?siteTag~in=tag", + now: NOW, + locale: "en", + ...over, + }; +} + +function expectValid(blocks: unknown) { + const result = validateBlockResponse({ blocks }, { pluginPagePaths: [PAGE_PATH, CONTENT_PATH] }); + if (!result.valid) { + throw new Error(`Block Kit validation failed:\n${result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`); + } + expect(result.valid).toBe(true); +} + +type AnyBlock = Record & { type: string }; + +function find(blocks: unknown[], type: string): AnyBlock[] { + const out: AnyBlock[] = []; + const walk = (value: unknown) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const record = value as AnyBlock; + if (record.type === type) out.push(record); + Object.values(record).forEach(walk); + } + }; + walk(blocks); + return out; +} + +function chartPoints(blocks: unknown[]): number[] { + const chart = find(blocks, "chart")[0] as unknown as { config: { series: Array<{ data: unknown[] }> } } | undefined; + return chart ? chart.config.series.map((s) => s.data.length) : []; +} + +describe("renderPage", () => { + it("names collections by label, and leaves paths that are no entry blank", () => { + const blocks = renderPage( + input({ + state: { ...input().state, collectionLabels: { home: "Startseite", posts: "Blog" } }, + topEntries: { + rows: [ + { path: "/", pageviews: 50, visits: 40 }, + { path: "/analytics", pageviews: 1, visits: 1 }, + ], + partial: false, + }, + }), + ); + const rows = find(blocks, "table")[0]!.rows as Array>; + expect(rows.map((r) => r.collection)).toEqual(["Startseite", ""]); + }); + + it("produces blocks the host accepts, links included", () => { + expectValid(renderPage(input())); + }); + + it("charts exactly the days in the range that have data", () => { + expect(chartPoints(renderPage(input({ range: 30 })))).toEqual([30, 30]); + expect(chartPoints(renderPage(input({ range: 7 })))).toEqual([7, 7]); + }); + + it("leaves a day Cloudflare never reported out of the chart rather than drawing a zero", () => { + const rollups = history(60).filter((r) => r.date !== addDays(TODAY, -3)); + expect(chartPoints(renderPage(input({ range: 7, rollups })))).toEqual([6, 6]); + }); + + it("shows a trend only when the previous period has data", () => { + const withHistory = find(renderPage(input({ range: 30 })), "stats")[0] as unknown as { items: Array<{ trend?: string }> }; + expect(withHistory.items[0]!.trend).toBeDefined(); + + const fresh = find(renderPage(input({ range: 30, rollups: history(10) })), "stats")[0] as unknown as { + items: Array<{ trend?: string; description?: string }>; + }; + expect(fresh.items[0]!.trend).toBeUndefined(); + expect(fresh.items[0]!.description).toMatch(/no earlier period/); + }); + + it("does not compare against a previous period that history only grazes", () => { + // A 90-day backfill leaves one day of the previous 90; comparing + // against it reported "+8,713%". + const grazed = find(renderPage(input({ range: 30, rollups: history(31) })), "stats")[0] as unknown as { + items: Array<{ trend?: string; description?: string }>; + }; + expect(grazed.items[0]!.trend).toBeUndefined(); + expect(grazed.items[0]!.description).toMatch(/no earlier period/); + }); + + it("offers each range as a button, marks the chosen one, and keeps it on refresh", () => { + // Buttons rather than a select: the host's select shows the raw value + // ("90") when closed, and its label pushes it out of line with the + // buttons beside it. + const blocks = renderPage(input({ range: 90 })); + expect(find(blocks, "select")).toHaveLength(0); + const ranges = find(blocks, "button").filter((b) => b.action_id === RANGE_ACTION); + expect(ranges.map((b) => b.value)).toEqual([7, 30, 90]); + expect(ranges.find((b) => b.value === 90)?.style).toBe("primary"); + expect(ranges.filter((b) => b.style === "primary")).toHaveLength(1); + const refresh = find(blocks, "button").find((b) => b.action_id === PAGE_REFRESH_ACTION); + expect(refresh?.value).toBe(90); + }); + + it("links to the provider's own dashboard when there is one, and not otherwise", () => { + const url = "https://dash.cloudflare.com/acct/web-analytics/overview?siteTag~in=tag"; + const links = find(renderPage(input()), "link") as unknown as Array<{ target: { kind: string; url?: string } }>; + expect(links.some((l) => l.target.kind === "external" && l.target.url === url)).toBe(true); + + const demo = find(renderPage(input({ dashboardUrl: null })), "link"); + expect(demo.some((l) => (l.target as { kind: string }).kind === "external")).toBe(false); + }); + + it("names entries by title and says how far back per-entry numbers reach", () => { + const blocks = renderPage( + input({ topEntries: { rows: [{ path: "/", pageviews: 5, visits: 4 }], coveredSince: addDays(TODAY, -7), partial: false } }), + ); + const text = JSON.stringify(blocks); + expect(text).toContain("Home"); + expect(text).toContain(formatDay(addDays(TODAY, -7), "en")); + }); + + it("stays inside the host's response limits at 90 days with a full page of entries", () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ path: `/blog/post-${i}/`, pageviews: 100 - i, visits: 50 })); + const entriesByPath = new Map(rows.map((r) => [r.path, entry(r.path, `A reasonably long entry title number ${r.path}`)])); + const state = { + ...input().state, + referrers: Array.from({ length: 10 }, (_, i) => ({ label: `ref-${i}.example`, visits: 10 - i })), + countries: Array.from({ length: 10 }, (_, i) => ({ label: "DE", visits: 10 - i })), + }; + expectValid( + renderPage(input({ range: 90, rollups: history(180), topEntries: { rows, partial: false }, entriesByPath, state })), + ); + }); + + it("explains an empty store instead of rendering zeroes", () => { + const blocks = renderPage(input({ rollups: [], topEntries: { rows: [], partial: false } })); + expectValid(blocks); + expect(find(blocks, "empty")).toHaveLength(1); + expect(find(blocks, "chart")).toHaveLength(0); + }); + + it("never calls demo data estimated by Cloudflare", () => { + const blocks = renderPage( + input({ state: { ...input().state, provider: "demo", estimated: true }, dashboardUrl: null }), + ); + const text = JSON.stringify(blocks); + expect(text).toMatch(/Demo data/); + expect(text).not.toMatch(/Cloudflare/); + }); +}); + +describe("parseRange", () => { + it("accepts the offered ranges as strings or numbers", () => { + expect(parseRange("90")).toBe(90); + expect(parseRange(7)).toBe(7); + }); + + it("falls back to 30 days for anything else", () => { + expect(parseRange("45")).toBe(30); + expect(parseRange(undefined)).toBe(30); + expect(parseRange({ range: 7 })).toBe(30); + }); +}); + +describe("summarizeDaily", () => { + const row = (date: string, path: string, pageviews: number) => ({ date, path, pageviews, visits: pageviews }); + + it("adds up each path across the days read", () => { + const out = summarizeDaily([row(TODAY, "/", 2), row(addDays(TODAY, -1), "/", 3), row(TODAY, "/a/", 1)], false); + expect(out.rows).toEqual([ + { path: "/", pageviews: 5, visits: 5 }, + { path: "/a/", pageviews: 1, visits: 1 }, + ]); + expect(out).toMatchObject({ coveredSince: addDays(TODAY, -1), partial: false }); + }); + + it("drops the oldest day when the read stopped part-way through it", () => { + // Rows arrive newest first; if more pages remain, the oldest day in + // hand may be missing paths, so it is left out rather than undercounted. + const out = summarizeDaily([row(TODAY, "/", 2), row(addDays(TODAY, -1), "/", 3)], true); + expect(out.rows).toEqual([{ path: "/", pageviews: 2, visits: 2 }]); + expect(out).toMatchObject({ coveredSince: TODAY, partial: true }); + }); +}); + +describe("merging the live answer with the store", () => { + const live = (rows: RollupRow[]) => ({ + totals: { pageviews: 0, visits: 0, sampleInterval: 1 }, + series: rows.map(({ fetchedAt: _f, ...row }) => row), + topPaths: [], + referrers: [], + countries: [], + truncated: false, + }); + + it("takes the live day over the stored one, since the store may be a tick behind", () => { + const merged = mergeDays([rollup(TODAY, 50)], live([rollup(TODAY, 64)]), NOW); + expect(merged).toEqual([expect.objectContaining({ date: TODAY, pageviews: 64 })]); + }); + + it("keeps stored days the live answer does not cover: older history and the previous period", () => { + const before = addDays(TODAY, -40); + const merged = mergeDays([rollup(before, 7)], live([rollup(TODAY, 64)]), NOW); + expect(merged.map((r) => r.date).sort()).toEqual([before, TODAY].sort()); + }); +}); diff --git a/packages/analytics/tests/panel.test.ts b/packages/analytics/tests/panel.test.ts new file mode 100644 index 0000000..2ec6759 --- /dev/null +++ b/packages/analytics/tests/panel.test.ts @@ -0,0 +1,146 @@ +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EntryRow } from "../src/store/rows.js"; +import { CONTENT_PATH } from "../src/ui/content.js"; +import { PAGE_PATH } from "../src/ui/page.js"; +import { PANEL_ID, renderPanel } from "../src/ui/panel.js"; +import { bridgeCalls } from "./bridge-calls.js"; +import { newHost, NOW, routableCollection, setState, synced } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +type AnyBlock = Record & { type: string }; + +function find(blocks: unknown, type: string): AnyBlock[] { + const out: AnyBlock[] = []; + const walk = (value: unknown) => { + if (Array.isArray(value)) value.forEach(walk); + else if (value && typeof value === "object") { + const record = value as AnyBlock; + if (record.type === type) out.push(record); + Object.values(record).forEach(walk); + } + }; + walk(blocks); + return out; +} + +const row = (path: string, over: Partial = {}): EntryRow => ({ + path, + collection: "posts", + entryId: `id${path}`, + translationGroup: `id${path}`, + locale: "en", + title: path, + status: "published", + views7: 0, + views30: 0, + updatedAt: NOW.toISOString(), + ...over, +}); + +async function put(runtime: PluginRuntimeTestHost, data: EntryRow) { + await runtime.fixtures.plugin.storage("entries", data.path, data); +} + +const stat = (blocks: unknown) => + (find(blocks, "stats")[0]?.items as Array<{ label: string; value: string }> | undefined)?.map((i) => [i.label, i.value]); + +describe("renderPanel", () => { + it("produces blocks the host accepts", () => { + const current = row("/de/a/", { locale: "de", translationGroup: "a", views7: 3, views30: 30 }); + const other = row("/en/a/", { locale: "en", translationGroup: "a", views7: 1, views30: 12 }); + const blocks = renderPanel({ current, members: [current, other], state: synced, now: NOW, locale: "en" }); + const result = validateBlockResponse({ blocks }, { pluginPagePaths: [PAGE_PATH, CONTENT_PATH] }); + expect(result.errors).toEqual([]); + }); +}); + +describe("the Views panel", () => { + it("shows the entry's own numbers", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + await put(host, row("/blog/post-0/", { entryId: id!, views7: 4, views30: 19 })); + await setState(host, synced); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", id!); + + expect(stat(response.blocks)).toEqual([ + ["Page views, last 7 days", "4"], + ["Page views, last 30 days", "19"], + ]); + expect(find(response.blocks, "context")[0]!.text).toContain("/blog/post-0/"); + }); + + it("adds the translations and their total", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + await put(host, row("/blog/post-0/", { entryId: id!, translationGroup: id!, views30: 30 })); + await put(host, row("/en/blog/post-0/", { entryId: "other", translationGroup: id!, locale: "de", views30: 12 })); + await put(host, row("/fr/blog/post-0/", { entryId: "gone", translationGroup: id!, locale: "fr", views30: 99, status: "deleted" })); + await setState(host, synced); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", id!); + + // Two cards only; the total sits in the context line. + expect(stat(response.blocks)).toHaveLength(2); + expect(find(response.blocks, "context")[0]!.text).toContain("All languages: 42 in 30 days"); + const rows = find(response.blocks, "table")[0]!.rows as Array>; + expect(rows.map((r) => [r.language, r.views30])).toEqual([ + ["en", 30], + ["de", 12], + ]); + }); + + it("says why there are no numbers for an entry without a page", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + await setState(host, synced); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", id!); + + expect(find(response.blocks, "empty")[0]).toMatchObject({ title: "No page for this entry yet" }); + }); + + it("opens for an author on their own entry", async () => { + // The analytics pages need plugins:read (EDITOR). The panel is the one + // place an author sees their own numbers. + host = await newHost(); + await routableCollection(host, 0); + const author = await host.fixtures.user({ email: "author@example.test", role: "author" }); + const item = await host.fixtures.content("posts", { + slug: "mine", + data: { title: "Mine" }, + status: "published", + publishedAt: NOW.toISOString(), + authorId: author.id, + }); + await put(host, row("/blog/mine/", { entryId: item.id, views30: 8 })); + await setState(host, synced); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", item.id, { user: author }); + + expect(stat(response.blocks)?.[1]).toEqual(["Page views, last 30 days", "8"]); + }); + + it("stays inside the call budget", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + await put(host, row("/blog/post-0/", { entryId: id!, translationGroup: id! })); + await put(host, row("/de/blog/post-0/", { entryId: "other", translationGroup: id!, locale: "de" })); + await setState(host, synced); + + const calls = await bridgeCalls(() => host!.admin.loadEditorPanel(PANEL_ID, "posts", id!)); + + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(10); + expect(calls.filter((c) => c === "storageQuery")).toHaveLength(2); + }); +}); diff --git a/packages/analytics/tests/paths.test.ts b/packages/analytics/tests/paths.test.ts new file mode 100644 index 0000000..c28af19 --- /dev/null +++ b/packages/analytics/tests/paths.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { isInternalPath, normalizeHost, normalizePath, siteHosts } from "../src/index/paths.js"; + +describe("normalizePath", () => { + it("folds the two spellings Cloudflare actually reported", () => { + // Straight from the 2026-09-20 pull: one page, two rows. + expect(normalizePath("/datenschutz")).toBe(normalizePath("/datenschutz/")); + }); + + it("honours an explicit trailing-slash policy", () => { + expect(normalizePath("/impressum", "always")).toBe("/impressum/"); + expect(normalizePath("/impressum/", "always")).toBe("/impressum/"); + expect(normalizePath("/impressum", "never")).toBe("/impressum"); + expect(normalizePath("/impressum/", "never")).toBe("/impressum"); + }); + + it("keeps the root as / under every policy", () => { + for (const policy of ["always", "never", "ignore"] as const) { + expect(normalizePath("/", policy)).toBe("/"); + expect(normalizePath("", policy)).toBe("/"); + expect(normalizePath("///", policy)).toBe("/"); + } + }); + + it("drops query and hash", () => { + expect(normalizePath("/blog/?utm_source=newsletter")).toBe("/blog/"); + expect(normalizePath("/blog#section")).toBe("/blog/"); + expect(normalizePath("/blog?a=1#b")).toBe("/blog/"); + }); + + it("percent-decodes so encoded and plain spellings agree", () => { + expect(normalizePath("/%C3%BCber-uns/")).toBe("/über-uns/"); + expect(normalizePath("/über-uns")).toBe("/über-uns/"); + }); + + it("keeps a path it cannot decode rather than throwing", () => { + // A crash here would cost the whole sync batch. + expect(() => normalizePath("/bad%zz/")).not.toThrow(); + expect(normalizePath("/bad%zz/")).toBe("/bad%zz/"); + }); + + it("collapses repeated slashes and adds the leading one", () => { + expect(normalizePath("blog/post")).toBe("/blog/post/"); + expect(normalizePath("//blog///post//")).toBe("/blog/post/"); + }); + + it("accepts an absolute URL and keeps only the path", () => { + expect(normalizePath("https://example.com/kontakt/")).toBe("/kontakt/"); + expect(normalizePath("https://example.com/kontakt?x=1")).toBe("/kontakt/"); + }); + + it("does not fold case, because paths are case-sensitive", () => { + expect(normalizePath("/Impressum/")).not.toBe(normalizePath("/impressum/")); + }); + + it("does not strip a leading segment that looks like a locale", () => { + // `/de/` is a locale on one site and a page on another. The index is + // built forward from getPublicUrl, so both sides already agree. + expect(normalizePath("/de/blog/")).toBe("/de/blog/"); + }); +}); + +describe("normalizeHost", () => { + it("folds case and the trailing dot", () => { + expect(normalizeHost("EXAMPLE.com")).toBe("example.com"); + expect(normalizeHost("example.com.")).toBe("example.com"); + }); + + it("keeps www, because it is a distinct requestHost value", () => { + // Folding it here collapsed the host allow-list into a duplicate + // pair and would have dropped every www visit from the filter. + expect(normalizeHost("www.example.com")).toBe("www.example.com"); + expect(normalizeHost("www.example.com")).not.toBe(normalizeHost("example.com")); + }); +}); + +describe("siteHosts", () => { + it("covers apex and www, because one site tag carries both", () => { + expect(siteHosts("https://example.com")).toEqual(["example.com", "www.example.com"]); + expect(siteHosts("https://www.example.com/")).toEqual(["example.com", "www.example.com"]); + }); + + it("returns nothing usable for a site URL it cannot parse", () => { + expect(siteHosts("not a url")).toEqual([]); + expect(siteHosts("")).toEqual([]); + }); +}); + +describe("isInternalPath", () => { + it("recognises admin traffic", () => { + expect(isInternalPath("/_emdash")).toBe(true); + expect(isInternalPath("/_emdash/admin/dashboard")).toBe(true); + }); + + it("does not catch a page that merely starts with the same letters", () => { + expect(isInternalPath("/_emdashboard/")).toBe(false); + expect(isInternalPath("/blog/")).toBe(false); + }); +}); diff --git a/packages/analytics/tests/plugin.test.ts b/packages/analytics/tests/plugin.test.ts new file mode 100644 index 0000000..8dd3d46 --- /dev/null +++ b/packages/analytics/tests/plugin.test.ts @@ -0,0 +1,371 @@ +import { validateBlocks } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GRAPHQL_ENDPOINT } from "../src/providers/cloudflare.js"; +import type { SyncState } from "../src/sync/scheduler.js"; + +/** + * End-to-end through the real sandbox: the plugin is built, loaded into + * workerd and driven through the production host bridge. + * + * Two things about this harness, both of which cost time to find: + * + * - A collection must be created with `routable: true`. `getPublicUrl()` + * checks `collectionInfo.routable` and answers `null` without it, so the + * index silently stays empty and it reads as a plugin bug rather than a + * missing fixture field. `urlPattern` alone is not enough. + * - `actions.content.*` do fire the plugin hooks, but deferred through + * `after()`: nothing awaits them and `dispose()` does not drain them, so + * a test that ends early leaves hook work running against a torn-down + * database. Fixtures therefore come from `fixtures.content()`, which + * writes the row without firing anything, and hooks are driven with + * `transport.invokeHook()`. The one test of the real pipeline waits for + * the last effect it produces. + * + * Plugin storage also outlives `dispose()` within a file, so assertions + * here name the row they are about instead of counting the collection. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +async function newHost() { + return await createPluginRuntimeTestHost({ + site: { url: "https://example.test", locale: "en", trailingSlash: "always" }, + }); +} + +/** A host with one routable collection holding one genuinely published entry. */ +async function hostWithEntry(title = "Hello", opts: { routable?: boolean; slug?: string } = {}) { + const runtime = await newHost(); + const collection = opts.routable === false ? "notes" : "posts"; + await runtime.fixtures.collection({ + slug: collection, + label: "Entries", + ...(opts.routable === false ? {} : { urlPattern: "/blog/{slug}" }), + routable: opts.routable !== false, + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + const item = await runtime.fixtures.content(collection, { + slug: opts.slug ?? "hello", + data: { title }, + status: "published", + publishedAt: new Date().toISOString(), + }); + return { runtime, id: item.id, collection, title }; +} + +/** Fire the hook the host would have fired for a real publish. */ +async function firePublish(runtime: PluginRuntimeTestHost, collection: string, id: string, title: string) { + await runtime.transport.invokeHook("content:afterPublish", { collection, content: { id, title } }); +} + +describe("scheduling without an Enable click", () => { + it("schedules both tasks from the widget's first page load", async () => { + // The PoC's own acceptance row, and the fix for the review's first + // blocker: `plugin:activate` is dispatched only by the admin Enable + // endpoint, so a site that registers the plugin through `plugins: []` + // never gets one. Nothing here calls activate. + host = await newHost(); + + await expect(host.inspect.scheduledTasks()).resolves.toEqual([]); + + await host.admin.loadWidget("traffic"); + + const tasks = await host.inspect.scheduledTasks(); + expect(tasks.map((t) => String(t.name))).toEqual(expect.arrayContaining(["sync", "reconcile"])); + expect(tasks.find((t) => t.name === "sync")?.schedule).toBe("*/15 * * * *"); + expect(tasks.find((t) => t.name === "reconcile")?.schedule).toBe("10 3 * * *"); + }); + + it("schedules from a content save as well", async () => { + const { runtime, id, collection, title } = await hostWithEntry(); + host = runtime; + await runtime.transport.invokeHook("content:afterSave", { + collection, + content: { id, title }, + isNew: false, + }); + const tasks = await runtime.inspect.scheduledTasks(); + expect(tasks.map((t) => String(t.name))).toContain("sync"); + }); + + it("schedules even when indexing the entry fails", async () => { + // Scheduling comes first in the handler precisely so a content-access + // problem cannot leave the plugin permanently unscheduled. + host = await newHost(); + await host.transport.invokeHook("content:afterSave", { + collection: "does-not-exist", + content: { id: "nope" }, + isNew: true, + }); + expect((await host.inspect.scheduledTasks()).map((t) => String(t.name))).toContain("sync"); + }); + + it("does not duplicate a task when every entry point fires", async () => { + // `CronAccess.schedule` upserts on (plugin, task); this asserts the + // upsert rather than trusting the documentation. + const { runtime, id, collection, title } = await hostWithEntry(); + host = runtime; + await runtime.admin.loadWidget("traffic"); + await runtime.transport.invokeHook("content:afterSave", { collection, content: { id, title }, isNew: true }); + await runtime.admin.loadWidget("traffic"); + + const tasks = await runtime.inspect.scheduledTasks(); + expect(tasks.filter((t) => t.name === "sync")).toHaveLength(1); + }); +}); + +describe("the widget through the sandbox", () => { + it("emits Block Kit the host accepts", async () => { + host = await newHost(); + const response = await host.admin.loadWidget("traffic"); + // The host validates the response itself before returning it, so + // reaching this line already proves it passed; asserting makes the + // intent explicit. + expect(validateBlocks(response.blocks).valid).toBe(true); + }); + + it("explains that the first sync has not run instead of showing zeroes", async () => { + host = await newHost(); + const response = await host.admin.loadWidget("traffic"); + expect(JSON.stringify(response.blocks)).toMatch(/first sync has not run/i); + }); + + it("renders for an editor, not only for an admin", async () => { + // The route declares `plugins:read`. Without that declaration + // dispatch falls back to `plugins:manage` and every editor gets a + // widget card that 403s before the plugin runs at all. + host = await newHost(); + const editor = await host.fixtures.user({ email: "editor@example.test", role: "editor" }); + const response = await host.admin.loadWidget("traffic", { user: editor }); + expect(validateBlocks(response.blocks).valid).toBe(true); + }); + + it("offers a refresh button", async () => { + host = await newHost(); + const response = await host.admin.loadWidget("traffic"); + expect(JSON.stringify(response.blocks)).toContain("analytics:refresh"); + }); +}); + +describe("the refresh button", () => { + it("names the missing credential rather than failing silently", async () => { + host = await newHost(); + const response = await host.admin.act("widget:traffic", "analytics:refresh"); + + expect(validateBlocks(response.blocks).valid).toBe(true); + expect(response.toast?.type).toBe("error"); + expect(String(response.toast?.message)).toMatch(/not configured yet/i); + expect(String(response.toast?.message)).toMatch(/Account Analytics/); + }); + + it("asks for a tick instead of syncing inside the request", async () => { + // A sync phase and a render do not both fit in one sandboxed + // invocation's ten bridge calls, so the button schedules the tick. + host = await newHost(); + await host.fixtures.plugin.setting("provider", "demo"); + const today = new Date().toISOString().slice(0, 10); + + const response = await host.admin.act("widget:traffic", "analytics:refresh"); + expect(response.toast).toMatchObject({ type: "success" }); + expect(String(response.toast?.message)).toMatch(/requested/i); + await expect(host.inspect.storage.get("rollup", today)).resolves.toBeNull(); + + host.scheduled.setTime(new Date()); + await host.scheduled.run(); + + await expect(host.inspect.storage.get("rollup", today)).resolves.not.toBeNull(); + expect((await host.inspect.scheduledTasks()).map((t) => String(t.name))).not.toContain("refresh"); + }); +}); + +describe("a tick without credentials", () => { + it("records the error in state so the next render can show it", async () => { + host = await newHost(); + await host.transport.invokeHook("cron", { name: "sync", scheduledAt: new Date().toISOString() }); + + const state = await host.inspect.kv.get("state"); + expect(state?.lastError).toMatch(/not configured yet/i); + expect(state?.lastErrorAt).toBeTruthy(); + + const response = await host.admin.loadWidget("traffic"); + expect(JSON.stringify(response.blocks)).toMatch(/not configured yet/i); + }); + + it("never calls Cloudflare", async () => { + // `api.cloudflare.com` is the only allowed host and there is no token, + // so the tick must fail on configuration before it opens a socket. + host = await newHost(); + await host.transport.invokeHook("cron", { name: "sync", scheduledAt: new Date().toISOString() }); + expect(host.http.requests()).toEqual([]); + }); +}); + +describe("the path to entry index", () => { + it("stores the published URL as the join key, with the site's trailing slash", async () => { + const { runtime, id, collection, title } = await hostWithEntry(); + host = runtime; + await firePublish(runtime, collection, id, title); + + // What the site actually serves has to be what the index stores, or + // the provider's reported path never matches and every entry reads + // zero while the dashboard looks perfectly healthy. + await expect(runtime.inspect.content.publicUrl("posts", id)).resolves.toBe("https://example.test/blog/hello/"); + + const row = await runtime.inspect.storage.get<{ + path: string; + collection: string; + entryId: string; + status: string; + views7: number; + }>("entries", "/blog/hello/"); + + expect(row).toMatchObject({ + path: "/blog/hello/", + collection: "posts", + entryId: id, + status: "published", + views7: 0, + }); + }); + + it("keeps the title so the widget can show more than a path", async () => { + const { runtime, id, collection, title } = await hostWithEntry("Hello World"); + host = runtime; + await firePublish(runtime, collection, id, title); + const row = await runtime.inspect.storage.get<{ title: string }>("entries", "/blog/hello/"); + expect(row?.title).toBe("Hello World"); + }); + + it("marks an unpublished entry rather than forgetting it", async () => { + // Past traffic stays attributable to the entry that earned it. + const { runtime, id, collection, title } = await hostWithEntry(); + host = runtime; + await firePublish(runtime, collection, id, title); + await runtime.transport.invokeHook("content:afterUnpublish", { collection, content: { id } }); + + // The row survives, marked — it is not deleted. + const row = await runtime.inspect.storage.get<{ status: string; entryId: string }>("entries", "/blog/hello/"); + expect(row).toMatchObject({ entryId: id, status: "unpublished" }); + }); + + it("indexes nothing for a collection that is not routable", async () => { + // An entry with no public URL has no page to attribute traffic to, + // and inventing one would poison the join. + const { runtime, id, collection, title } = await hostWithEntry("Private", { routable: false, slug: "private" }); + host = runtime; + await firePublish(runtime, collection, id, title); + + await expect(runtime.inspect.content.publicUrl(collection, id)).resolves.toBeNull(); + + const rows = await runtime.inspect.storage.list<{ collection: string }>("entries"); + expect(rows.some((r) => r.data.collection === collection)).toBe(false); + }); + + it("indexes an entry published through EmDash itself, with no test-fired hook", async () => { + const runtime = await newHost(); + host = runtime; + await runtime.fixtures.collection({ + slug: "posts", + label: "Entries", + urlPattern: "/blog/{slug}", + routable: true, + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + const created = await runtime.actions.content.create("posts", { slug: "live", data: { title: "Live" } }); + if (!created.success) throw new Error(created.error.message); + const id = created.data.item.id; + await runtime.actions.content.publish("posts", id); + + await vi.waitFor(async () => { + await expect(runtime.inspect.storage.get("entries", "/blog/live/")).resolves.toMatchObject({ + entryId: id, + status: "published", + }); + expect((await runtime.inspect.scheduledTasks()).map((t) => String(t.name))).toContain("sync"); + }); + }); +}); + +describe("the manifest the host actually loaded", () => { + it("declares the capabilities, storage and route permission it needs", async () => { + host = await newHost(); + const manifest = host.manifest as unknown as { + capabilities: string[]; + allowedHosts: string[]; + storage: Record; + routes?: Array<{ name: string; permission?: string }>; + }; + + expect(manifest.capabilities).toEqual(["content:read", "schema:read", "network:request"]); + expect(manifest.allowedHosts).toEqual(["api.cloudflare.com"]); + expect(Object.keys(manifest.storage).sort()).toEqual(["daily", "entries", "rollup"]); + // Sorting the content page by these needs them declared as indexes. + expect(manifest.storage.entries!.indexes).toEqual( + expect.arrayContaining(["path", "collection", "entryId", "translationGroup", "views30", "views7"]), + ); + expect(manifest.routes?.find((r) => r.name === "admin")?.permission).toBe("plugins:read"); + }); +}); + +describe("the analytics page on day one", () => { + it("shows the provider's numbers before any sync has run", async () => { + // Waiting for the store would take hours to days on a fresh install, + // while the provider's own dashboard answers at once. + vi.stubEnv("EMDASH_ENCRYPTION_KEY", "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + host = await newHost(); + await host.actions.plugin.updateSettings({ cfApiToken: "cfat_test", cfAccountId: "acct-1", cfSiteTag: "tag-1" }); + const today = new Date().toISOString().slice(0, 10); + await host.http.respond( + GRAPHQL_ENDPOINT, + new Response( + JSON.stringify({ + data: { + viewer: { + accounts: [ + { + totals: [{ count: 4321, sum: { visits: 1234 }, avg: { sampleInterval: 1 } }], + series: [{ count: 4321, sum: { visits: 1234 }, avg: { sampleInterval: 1 }, dimensions: { date: today } }], + pages: [{ count: 999, sum: { visits: 500 }, avg: { sampleInterval: 1 }, dimensions: { requestPath: "/blog/hello/" } }], + refs: [{ count: 10, sum: { visits: 9 }, dimensions: { refererHost: "news.example" } }], + geo: [], + }, + ], + }, + }, + }), + { headers: { "Content-Type": "application/json" } }, + ), + ); + + const response = await host.admin.act("/analytics", "analytics:range", { value: 7 }); + const text = JSON.stringify(response.blocks); + expect(text).toContain("/blog/hello/"); + expect(text).toContain("news.example"); + expect(text).not.toMatch(/No analytics yet/); + await expect(host.inspect.storage.list("rollup")).resolves.toEqual([]); + }); + + it("never asks for more than the exact window, whatever the range", async () => { + // A sampled 30-day answer put a week's 25 page views on one day as + // 300. Older days come from the store or are not shown. + vi.stubEnv("EMDASH_ENCRYPTION_KEY", "emdash_enc_v1_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + host = await newHost(); + await host.actions.plugin.updateSettings({ cfApiToken: "cfat_test", cfAccountId: "acct-1", cfSiteTag: "tag-1" }); + const empty = { data: { viewer: { accounts: [{ totals: [], series: [], pages: [], refs: [], geo: [] }] } } }; + await host.http.respond(GRAPHQL_ENDPOINT, new Response(JSON.stringify(empty), { headers: { "Content-Type": "application/json" } })); + + await host.admin.act("/analytics", "analytics:range", { value: 30 }); + + const body = JSON.parse(new TextDecoder().decode(host.http.requests()[0]!.body)) as { variables: { since: string } }; + const exactStart = new Date(Date.now() - 6 * 86_400_000).toISOString().slice(0, 10); + expect(body.variables.since).toBe(exactStart); + }); +}); diff --git a/packages/analytics/tests/rows.test.ts b/packages/analytics/tests/rows.test.ts new file mode 100644 index 0000000..40c4f25 --- /dev/null +++ b/packages/analytics/tests/rows.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; + +import { dailyId, decideWrite, parseDailyId, sumWindow, type DailyRow } from "../src/store/rows.js"; + +// Yesterday: closed, so not provisional, but not yet past the freeze +// threshold. The tests that are about comparison rather than freezing need +// a day that is still writable, or freezing answers first and they pass +// for the wrong reason. +const row = (over: Partial = {}): DailyRow => ({ + date: "2026-09-19", + path: "/", + pageviews: 10, + visits: 8, + sampleInterval: 1, + fetchedAt: "2026-09-20T10:00:00.000Z", + ...over, +}); + +const TODAY = "2026-09-20"; + +describe("daily ids", () => { + it("round-trips", () => { + expect(parseDailyId(dailyId("2026-09-18", "/blog/"))).toEqual({ date: "2026-09-18", path: "/blog/" }); + }); + + it("survives a separator inside the path", () => { + // Nothing forbids a pipe in a URL path, and a greedy split would + // silently truncate the path and merge two pages into one row. + const id = dailyId("2026-09-18", "/search/a|b/"); + expect(parseDailyId(id)).toEqual({ date: "2026-09-18", path: "/search/a|b/" }); + }); + + it("returns null for something that is not an id", () => { + expect(parseDailyId("nonsense")).toBeNull(); + }); +}); + +describe("decideWrite", () => { + it("writes when there is nothing stored", () => { + expect(decideWrite(undefined, row(), TODAY)).toEqual({ action: "write" }); + }); + + it("writes when the numbers moved", () => { + expect(decideWrite(row({ pageviews: 10 }), row({ pageviews: 12 }), TODAY)).toEqual({ action: "write" }); + }); + + it("skips an identical row rather than spending D1 writes on it", () => { + // Every skipped put is ~5 fewer written rows; at a 15-minute cadence + // that is the difference between 2.2x the free budget and nothing. + expect(decideWrite(row(), row(), TODAY)).toEqual({ action: "skip", reason: "unchanged" }); + }); + + it("never overwrites a frozen row", () => { + const frozen = row({ date: "2026-09-10", sampleInterval: 1 }); + const fresh = row({ date: "2026-09-10", pageviews: 999, sampleInterval: 1 }); + expect(decideWrite(frozen, fresh, TODAY)).toEqual({ action: "skip", reason: "frozen" }); + }); + + it("never replaces an exact reading with an estimate", () => { + // The guard that makes an accidental wide query harmless instead of + // destructive: a sampled response reports multiples of ten and would + // otherwise stamp over exact history. + const exact = row({ date: "2026-09-19", pageviews: 7, sampleInterval: 1 }); + const sampled = row({ date: "2026-09-19", pageviews: 10, sampleInterval: 10 }); + expect(decideWrite(exact, sampled, TODAY)).toEqual({ action: "skip", reason: "would-downgrade" }); + }); + + it("does let an exact reading replace an estimate", () => { + const sampled = row({ date: "2026-09-19", pageviews: 10, sampleInterval: 10 }); + const exact = row({ date: "2026-09-19", pageviews: 7, sampleInterval: 1 }); + expect(decideWrite(sampled, exact, TODAY)).toEqual({ action: "write" }); + }); + + it("keeps today writable all day", () => { + const stored = row({ date: TODAY, pageviews: 3 }); + const later = row({ date: TODAY, pageviews: 9 }); + expect(decideWrite(stored, later, TODAY)).toEqual({ action: "write" }); + }); + + it("reports frozen ahead of unchanged, so the reason is the useful one", () => { + const old = row({ date: "2026-09-10" }); + expect(decideWrite(old, row({ date: "2026-09-10" }), TODAY)).toEqual({ action: "skip", reason: "frozen" }); + }); +}); + +describe("sumWindow", () => { + it("adds the days up and flags nothing when every row is exact and closed", () => { + expect( + sumWindow( + [ + { date: "2026-09-18", pageviews: 4, visits: 3, sampleInterval: 1 }, + { date: "2026-09-19", pageviews: 6, visits: 5, sampleInterval: 1 }, + ], + TODAY, + ), + ).toEqual({ pageviews: 10, visits: 8, estimated: false, provisional: false }); + }); + + it("flags an estimate when any day was sampled", () => { + const out = sumWindow([{ date: "2026-09-12", pageviews: 10, visits: 10, sampleInterval: 10 }], TODAY); + expect(out.estimated).toBe(true); + }); + + it("flags provisional when today is in the window, and still counts it", () => { + const out = sumWindow([{ date: TODAY, pageviews: 5, visits: 5, sampleInterval: 1 }], TODAY); + expect(out).toMatchObject({ pageviews: 5, provisional: true }); + }); + + it("returns zeroes for an empty window without throwing", () => { + expect(sumWindow([], TODAY)).toEqual({ pageviews: 0, visits: 0, estimated: false, provisional: false }); + }); +}); diff --git a/packages/analytics/tests/setup-sync.test.ts b/packages/analytics/tests/setup-sync.test.ts new file mode 100644 index 0000000..4d3f85f --- /dev/null +++ b/packages/analytics/tests/setup-sync.test.ts @@ -0,0 +1,125 @@ +import { validateBlockResponse } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GRAPHQL_ENDPOINT, RUM_DATASET } from "../src/providers/cloudflare.js"; +import { WAITING_KEY } from "../src/sync/scheduler.js"; +import { SETUP_ACTION } from "../src/ui/page.js"; +import { graphql, newHost, NOW } from "./host.js"; + +/** + * The setup check through the real sandbox, bridge and route, reproducing + * each failure from the state a real site would be in. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +type Row = Record; + +async function openSetup(runtime: PluginRuntimeTestHost) { + const response = await runtime.admin.act("/analytics", SETUP_ACTION, { value: 30 }); + const pages = (runtime.manifest.admin?.pages ?? []).map((page) => page.path); + expect(validateBlockResponse(response, { pluginPagePaths: pages }).valid).toBe(true); + const checks = response.blocks.find((b) => b.block_id === "analytics:setup:checks") as { rows: Row[] } | undefined; + return { response, rows: checks?.rows ?? [], text: JSON.stringify(response.blocks) }; +} + +function row(rows: Row[], check: string): Row | undefined { + return rows.find((r) => r.check === check); +} + +function discovery(rows: Array<{ siteTag: string; host: string; count: number }>) { + return graphql({ + [RUM_DATASET]: rows.map((r) => ({ count: r.count, dimensions: { siteTag: r.siteTag, requestHost: r.host } })), + }); +} + +describe("the setup check", () => { + it("is one button away on the analytics page, also before any data", async () => { + host = await newHost(); + const page = await host.admin.loadPage("/analytics"); + expect(JSON.stringify(page.blocks)).toMatch(/"action_id":"analytics:setup"/); + }); + + it("finds a hostname filter that excludes the host Cloudflare reports", async () => { + // The site URL is example.test, so the plugin counts example.test and + // www.example.test; the beacon reports under another name, and every + // number would read zero without an error anywhere. + host = await newHost("cloudflare"); + await host.http.respond(GRAPHQL_ENDPOINT, discovery([{ siteTag: "tag-1", host: "www.other.test", count: 42 }])); + + const { rows, text } = await openSetup(host); + expect(row(rows, "Hostnames")).toMatchObject({ + status: "Problem", + detail: expect.stringMatching( + /reports this site tag under www\.other\.test, but the plugin counts only example\.test/, + ), + }); + expect(row(rows, "Site tag")).toMatchObject({ status: "OK" }); + expect(text).toMatch(/Sites on this account/); + }); + + it("finds a site tag that has no traffic and lists the tags that do", async () => { + host = await newHost("cloudflare"); + await host.http.respond(GRAPHQL_ENDPOINT, discovery([{ siteTag: "real-tag", host: "example.test", count: 42 }])); + + const { rows, text } = await openSetup(host); + expect(row(rows, "Site tag")).toMatchObject({ status: "Problem" }); + expect(text).toMatch(/real-tag/); + }); + + it("reports Cloudflare's refusal as the access problem", async () => { + host = await newHost("cloudflare"); + await host.http.respond(GRAPHQL_ENDPOINT, new Response("forbidden", { status: 403 })); + + const { rows } = await openSetup(host); + expect(row(rows, "Cloudflare access")).toMatchObject({ status: "Problem", detail: expect.stringMatching(/403/) }); + }); + + it("finds a site without a stored URL", async () => { + host = await createPluginRuntimeTestHost({ site: { url: "", locale: "en", trailingSlash: "always" } }); + await host.fixtures.plugin.setting("provider", "demo"); + + const { rows } = await openSetup(host); + expect(row(rows, "Site URL")).toMatchObject({ status: "Problem", detail: expect.stringMatching(/emdash:site_url/) }); + }); + + it("finds a scheduler that never ran after the dashboard scheduled the sync", async () => { + host = await newHost(); + await host.admin.loadWidget("traffic"); + await host.fixtures.plugin.kv(WAITING_KEY, new Date(NOW.getTime() - 2 * 3_600_000).toISOString()); + + const { rows, text } = await openSetup(host); + expect(row(rows, "Scheduled sync")).toMatchObject({ + status: "Problem", + detail: expect.stringMatching(/has never run/), + }); + expect(text).toMatch(/createScheduledHandler/); + }); + + it("waits on a fresh install instead of calling it broken", async () => { + host = await newHost(); + await host.admin.loadWidget("traffic"); + + const { rows } = await openSetup(host); + expect(row(rows, "Scheduled sync")).toMatchObject({ status: "Waiting" }); + }); +}); + +describe("the waiting mark", () => { + it("is written by the first dashboard visit and kept by later ones", async () => { + host = await newHost(); + await host.admin.loadWidget("traffic"); + const first = await host.inspect.kv.get(WAITING_KEY); + expect(first).toEqual(expect.any(String)); + + await host.admin.loadWidget("traffic"); + expect(await host.inspect.kv.get(WAITING_KEY)).toBe(first); + }); +}); diff --git a/packages/analytics/tests/setup.test.ts b/packages/analytics/tests/setup.test.ts new file mode 100644 index 0000000..b599ba9 --- /dev/null +++ b/packages/analytics/tests/setup.test.ts @@ -0,0 +1,224 @@ +import { validateBlocks } from "@emdash-cms/blocks/server"; +import { describe, expect, it } from "vitest"; + +import { failure } from "../src/i18n.js"; +import { checkSetup, intervalMinutes, renderSetup, type CheckId, type SetupFacts } from "../src/ui/setup.js"; + +/** + * Every setup failure the check knows, each from the state that produces + * it, and the sentence it has to answer with. The runtime-host tests in + * `setup-sync.test.ts` reproduce the same states through the real bridge. + */ + +const NOW = new Date("2026-09-25T12:00:00.000Z"); +const minutesAgo = (n: number) => new Date(NOW.getTime() - n * 60_000).toISOString(); + +function healthy(): SetupFacts { + return { + provider: "cloudflare", + missing: [], + siteTag: "tag-1", + hosts: ["example.test", "www.example.test"], + siteUrl: "https://example.test", + discovery: { ok: true, value: [{ siteTag: "tag-1", hosts: ["example.test"], pageviews: 120 }] }, + tasks: [{ name: "sync", schedule: "*/15 * * * *", nextRunAt: minutesAgo(-10), lastRunAt: minutesAgo(5) }], + state: { phase: "overview", lastSync: minutesAgo(5), indexComplete: true, indexed: 14 }, + now: NOW, + }; +} + +function check(facts: SetupFacts, id: CheckId, locale = "en") { + const found = checkSetup(facts, locale).find((c) => c.id === id); + if (!found) throw new Error(`no ${id} check`); + return found; +} + +function problems(facts: SetupFacts): CheckId[] { + return checkSetup(facts, "en") + .filter((c) => c.status === "problem") + .map((c) => c.id); +} + +describe("a complete setup", () => { + it("has no problem and says so", () => { + const facts = healthy(); + expect(problems(facts)).toEqual([]); + const blocks = renderSetup(facts, 30, "en"); + expect(validateBlocks(blocks).valid).toBe(true); + expect(JSON.stringify(blocks)).toMatch(/Everything the numbers depend on is in place/); + }); + + it("names what it counts and what it leaves out", () => { + const facts = healthy(); + facts.discovery = { + ok: true, + value: [{ siteTag: "tag-1", hosts: ["example.test", "tag-1-preview.workers.dev"], pageviews: 120 }], + }; + expect(check(facts, "hosts")).toMatchObject({ + status: "ok", + detail: "Counted: example.test. Not counted: tag-1-preview.workers.dev.", + }); + }); +}); + +describe("each failure, with the sentence that names its fix", () => { + it("a missing token also explains the encryption key", () => { + const facts = { ...healthy(), missing: ["cfApiToken"], discovery: null }; + expect(check(facts, "credentials").detail).toMatch(/add an API token with Account → Account Analytics → Read/); + expect(check(facts, "credentials").detail).toMatch(/npx emdash secrets generate/); + expect(check(facts, "siteTag").status).toBe("skipped"); + }); + + it("a token Cloudflare refuses", () => { + const facts = { ...healthy(), discovery: failure("cfForbidden") }; + expect(check(facts, "access")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/rejected the token \(403\)/), + }); + expect(check(facts, "hosts").status).toBe("skipped"); + }); + + it("a Zone-scoped token", () => { + const facts = { ...healthy(), discovery: failure("cfZoneToken") }; + expect(check(facts, "access").detail).toMatch(/Zone Analytics Read/); + }); + + it("no site tag, with the account's sites to copy one from", () => { + const facts = { ...healthy(), siteTag: "" }; + expect(check(facts, "siteTag")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/Copy one from the list below/), + }); + const text = JSON.stringify(renderSetup(facts, 30, "en")); + expect(text).toMatch(/Sites on this account/); + expect(text).toMatch(/tag-1/); + }); + + it("no site tag on an account without any traffic", () => { + const facts = { ...healthy(), siteTag: "", discovery: { ok: true as const, value: [] } }; + expect(check(facts, "siteTag").detail).toMatch(/no Web Analytics traffic in the last 7 days/); + }); + + it("a site tag with no traffic, which is what the beacon token looks like", () => { + const facts = { ...healthy(), siteTag: "beacon-token" }; + expect(check(facts, "siteTag")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/token in the beacon snippet is a different value/), + }); + expect(JSON.stringify(renderSetup(facts, 30, "en"))).toMatch(/Sites on this account/); + }); + + it("a hostname filter that excludes every host Cloudflare reports", () => { + const facts = healthy(); + facts.discovery = { ok: true, value: [{ siteTag: "tag-1", hosts: ["www.other.test"], pageviews: 120 }] }; + expect(check(facts, "hosts")).toMatchObject({ + status: "problem", + detail: + "Cloudflare reports this site tag under www.other.test, but the plugin counts only example.test, www.example.test. Change Hostnames to count in the plugin's settings.", + }); + }); + + it("no stored site URL, which leaves the index nothing to match", () => { + const facts = { ...healthy(), siteUrl: "", state: { ...healthy().state, indexed: 0 } }; + expect(check(facts, "siteUrl")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/emdash:site_url/), + }); + expect(check(facts, "index").status).toBe("skipped"); + }); + + it("a finished index that matched nothing although the URL is set", () => { + const facts = { ...healthy(), state: { ...healthy().state, indexed: 0 } }; + expect(check(facts, "index")).toMatchObject({ status: "problem", detail: expect.stringMatching(/URL patterns/) }); + }); + + it("a host without a scheduler", () => { + const facts = { ...healthy(), tasks: null }; + expect(check(facts, "scheduler")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/runs no scheduled tasks/), + }); + }); + + it("a sync that is not scheduled", () => { + const facts = { ...healthy(), tasks: [] }; + expect(check(facts, "scheduler").detail).toMatch(/not scheduled yet/); + }); + + it("a sync that stopped running, with the Cron Trigger and handler to add", () => { + const facts = healthy(); + facts.tasks = [{ name: "sync", schedule: "*/15 * * * *", nextRunAt: minutesAgo(-10), lastRunAt: minutesAgo(45) }]; + expect(check(facts, "scheduler")).toMatchObject({ + status: "problem", + detail: "Last run 45 minutes ago, but scheduled every 15 minutes: the site's scheduler is not running on time.", + }); + const text = JSON.stringify(renderSetup(facts, 30, "en")); + expect(text).toMatch(/createScheduledHandler/); + expect(text).toMatch(/npx emdash doctor/); + }); + + it("a sync that never ran since the dashboard first scheduled it", () => { + const facts: SetupFacts = { + ...healthy(), + tasks: [{ name: "sync", schedule: "*/15 * * * *", nextRunAt: minutesAgo(-10), lastRunAt: null }], + state: { phase: "overview" }, + waitingSince: minutesAgo(120), + }; + expect(check(facts, "scheduler")).toMatchObject({ + status: "problem", + detail: expect.stringMatching(/has never run/), + }); + }); + + it("a requested sync still waiting long after it was due", () => { + const facts = healthy(); + facts.tasks = [ + ...(facts.tasks ?? []), + { name: "refresh", schedule: minutesAgo(30), nextRunAt: minutesAgo(30), lastRunAt: null }, + ]; + expect(check(facts, "scheduler").detail).toMatch(/requested 30 minutes ago has not run/); + }); + + it("a failing sync, in the reader's language", () => { + const facts = healthy(); + facts.state = { + ...facts.state, + lastError: "Cloudflare rejected the token (403). It needs Account → Account Analytics → Read.", + lastProblem: { key: "cfForbidden" }, + lastErrorAt: minutesAgo(3), + }; + expect(check(facts, "lastSync").detail).toMatch(/^Last attempt failed 3 minutes ago: Cloudflare rejected/); + expect(check(facts, "lastSync", "de").detail).toMatch(/Cloudflare hat das Token abgelehnt/); + }); + + it("counts problems in the banner", () => { + const facts = { ...healthy(), siteUrl: "", tasks: [] }; + expect(JSON.stringify(renderSetup(facts, 30, "en"))).toMatch(/2 problems stop or distort the numbers/); + }); +}); + +describe("before anything is known", () => { + it("waits instead of reporting a fresh install as broken", () => { + const facts: SetupFacts = { + ...healthy(), + tasks: [{ name: "sync", schedule: "*/15 * * * *", nextRunAt: minutesAgo(-10), lastRunAt: null }], + state: { phase: "overview" }, + waitingSince: minutesAgo(5), + }; + expect(problems(facts)).toEqual([]); + expect(check(facts, "scheduler").status).toBe("waiting"); + expect(check(facts, "index").status).toBe("waiting"); + }); + + it("checks nothing on Cloudflare while demo data is on", () => { + const facts = { ...healthy(), provider: "demo" as const, discovery: null }; + expect(checkSetup(facts, "en").map((c) => c.id)).not.toContain("access"); + expect(check(facts, "source").detail).toMatch(/Demo data/); + }); +}); + +describe("intervalMinutes", () => { + it("reads the four schedules the settings offer", () => { + expect(["*/15 * * * *", "*/30 * * * *", "0 * * * *", "0 */6 * * *"].map(intervalMinutes)).toEqual([15, 30, 60, 360]); + }); +}); diff --git a/packages/analytics/tests/views-sync.test.ts b/packages/analytics/tests/views-sync.test.ts new file mode 100644 index 0000000..8d7b906 --- /dev/null +++ b/packages/analytics/tests/views-sync.test.ts @@ -0,0 +1,237 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { EntryRow } from "../src/store/rows.js"; +import { olderWindow } from "../src/store/views.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { addDays, enumerateDays } from "../src/sync/window.js"; +import { failNextCall } from "./bridge-calls.js"; +import { newHost, NOW, pathsOf, routableCollection, seedDaily, seedEntries, setState, synced, tick, TODAY } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + vi.unstubAllEnvs(); +}); + +const YESTERDAY = addDays(TODAY, -1); +const OLDER_DAYS = enumerateDays(olderWindow(TODAY).since, olderWindow(TODAY).until); + +/** A caught-up site whose older pass last ran yesterday, with the paths slot next. */ +const due: SyncState = { ...synced, phase: "paths", lastWork: "paths", older: { day: YESTERDAY, complete: true } }; + +async function state(runtime: PluginRuntimeTestHost) { + return await runtime.inspect.kv.get("state"); +} + +async function entry(runtime: PluginRuntimeTestHost, path: string) { + return (await runtime.inspect.storage.get("entries", path)) as EntryRow | null; +} + +/** Tick until today's older pass has finished, or give up. */ +async function untilOlderDone(runtime: PluginRuntimeTestHost, limit = 30) { + for (let i = 0; i < limit; i++) { + const s = await state(runtime); + if (s?.older?.day === TODAY && s.older.complete) return i; + await tick(runtime)(); + } + throw new Error("the older pass did not finish"); +} + +describe("the older pass", () => { + it("sums each path's older days, whole, across page and tick boundaries", async () => { + host = await newHost(); + // 25 paths of 22 rows each are six pages of up to 100, more than one + // tick reads, so one path's rows straddle two ticks. Its first part + // has to be carried, not written: the second tick's write is absolute. + const paths = pathsOf(25); + await seedEntries(host, paths); + await seedDaily(host, paths, OLDER_DAYS); + // Days on either side of the window must not count. + await seedDaily(host, paths, [addDays(TODAY, -30), addDays(TODAY, -7)]); + await setState(host, due); + + expect(await untilOlderDone(host)).toBeGreaterThan(2); + + for (const path of paths) { + const row = await entry(host, path); + expect(row, path).toMatchObject({ olderViews: 22 * 3, olderDay: TODAY }); + // Paths ticks ran in between and added the recent part. + expect(row?.views30, path).toBe(22 * 3 + (row?.recentViews ?? 0)); + } + }); + + it("finishes a pass larger than one tick can read", async () => { + host = await newHost(); + const paths = pathsOf(300, "/q-"); + await seedEntries(host, paths); + await seedDaily(host, paths, [OLDER_DAYS[5]!]); + await setState(host, due); + + const ticks = await untilOlderDone(host, 60); + + expect(ticks).toBeGreaterThan(2); + const rows = await host.inspect.storage.list("entries"); + expect(rows.filter((r) => r.data.olderViews !== 3).map((r) => r.id)).toEqual([]); + }); + + it("takes turns with the paths tick instead of holding the slot", async () => { + host = await newHost(); + const paths = pathsOf(300, "/q-"); + await seedEntries(host, paths); + await seedDaily(host, paths, [OLDER_DAYS[5]!]); + await setState(host, due); + + const work: Array = []; + for (let i = 0; i < 8; i++) { + await tick(host)(); + const s = await state(host); + if (s?.phase === "overview") work.push(s.lastWork); + } + + expect(work).toEqual(["older", "paths", "older", "paths"]); + }); + + it("leaves the slot to the paths tick once today's pass is done", async () => { + host = await newHost(); + await seedEntries(host, pathsOf(2)); + await setState(host, { ...due, lastWork: "older", older: { day: TODAY, complete: true } }); + + await tick(host)(); + + expect((await state(host))?.lastWork).toBe("paths"); + }); + + it("drops an older part the finished pass did not reach", async () => { + // All of this entry's older traffic was on the day that left the + // window overnight: no rows today, so the pass never writes it. + host = await newHost(); + const [path] = pathsOf(1); + await seedEntries(host, [path!]); + const stale = (await entry(host, path!))!; + await host.fixtures.plugin.storage("entries", path!, { + ...stale, + olderViews: 40, + olderDay: YESTERDAY, + views30: 40, + }); + await setState(host, due); + + await untilOlderDone(host); + // The paths tick after it is the one that notices. + for (let i = 0; i < 4 && (await state(host))?.lastWork !== "paths"; i++) await tick(host)(); + + const after = await entry(host, path!); + expect(after).toMatchObject({ olderViews: 0, olderDay: TODAY }); + expect(after?.views30).toBe(after?.recentViews); + }); +}); + +describe("the repair walk", () => { + it("repairs rows a previous build stored, keeping their view counts", async () => { + host = await newHost(); + const [original] = await routableCollection(host, 1); + const translation = await host.fixtures.content("posts", { + slug: "post-0-de", + locale: "de", + translationOf: original, + data: { title: "Beitrag 0" }, + status: "published", + publishedAt: NOW.toISOString(), + }); + // What 6b4f7bc's walk stored: the translation as its own group, no + // publishedAt. + const counts = { views7: 12, views30: 30, recentViews: 14, olderViews: 16, olderDay: TODAY }; + await host.fixtures.plugin.storage("entries", "/blog/post-0-de/", { + path: "/blog/post-0-de/", + collection: "posts", + entryId: translation.id, + translationGroup: translation.id, + locale: "de", + // As the save hook read it through the schema's title field, which + // the walk's listing may not match. + title: "Beitrag 0: der Titel", + status: "published", + ...counts, + updatedAt: NOW.toISOString(), + }); + const { indexVersion: _, ...legacy } = due; + await setState(host, legacy); + + for (let i = 0; i < 4 && (await state(host))?.lastWork !== "index"; i++) await tick(host)(); + + const repaired = await entry(host, "/blog/post-0-de/"); + expect(repaired).toMatchObject({ translationGroup: original, title: "Beitrag 0: der Titel", ...counts }); + expect(repaired?.publishedAt).toBeTruthy(); + }); + + it("leaves the next slot to the paths tick after it fails", async () => { + host = await newHost(); + await routableCollection(host, 2); + await seedEntries(host, pathsOf(2)); + const { indexVersion: _, ...legacy } = due; + await setState(host, legacy); + + failNextCall("contentList"); + await tick(host)(); + expect((await state(host))?.lastProblem?.key).toBe("indexingFailed"); + + // A walk that failed must not get the slot straight back, or a + // persistent failure would stop the view counts for good. + await tick(host)(); + expect((await state(host))?.lastWork).toBe("paths"); + expect((await host.inspect.storage.list("daily")).length).toBeGreaterThan(0); + }); + + it("records the walk's version once it completes, so it does not run again", async () => { + host = await newHost(); + await routableCollection(host, 2); + const { indexVersion: _, ...legacy } = due; + await setState(host, legacy); + + for (let i = 0; i < 12 && (await state(host))?.indexVersion === undefined; i++) await tick(host)(); + + const s = await state(host); + expect(s?.indexVersion).toBeGreaterThan(1); + expect(s?.rebuild).toBeUndefined(); + // Kept for the analytics page, which names collections without + // asking the schema. + expect(s?.collectionLabels).toEqual({ posts: "Posts" }); + }); +}); + +describe("a slug change", () => { + it("marks the old path moved, so the entry is listed once", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + await host.transport.invokeHook("content:afterSave", { collection: "posts", content: { id, title: "Post 0" } }); + const before = (await entry(host, "/blog/post-0/"))!; + await host.fixtures.plugin.storage("entries", "/blog/post-0/", { ...before, views30: 17 }); + + await host.actions.content.update("posts", id!, { slug: "renamed" }); + await host.transport.invokeHook("content:afterSave", { collection: "posts", content: { id, title: "Post 0" } }); + + await expect(entry(host, "/blog/post-0/")).resolves.toMatchObject({ status: "moved", views30: 17 }); + await expect(entry(host, "/blog/renamed/")).resolves.toMatchObject({ status: "published", entryId: id }); + }); +}); + +describe("publishedAt", () => { + it("is stored by the content hooks", async () => { + host = await newHost(); + const [id] = await routableCollection(host, 1); + const item = await host.fixtures.content("posts", { + slug: "later", + data: { title: "Later" }, + status: "published", + publishedAt: "2026-09-01T08:00:00.000Z", + }); + + await host.transport.invokeHook("content:afterPublish", { collection: "posts", content: item }); + + await expect(entry(host, "/blog/later/")).resolves.toMatchObject({ publishedAt: "2026-09-01T08:00:00.000Z" }); + expect(id).toBeTruthy(); + }); +}); diff --git a/packages/analytics/tests/views.test.ts b/packages/analytics/tests/views.test.ts new file mode 100644 index 0000000..89e41ff --- /dev/null +++ b/packages/analytics/tests/views.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; + +import type { EntryRow } from "../src/store/rows.js"; +import { foldByPath, olderWindow, recentViewsOf, withOlder, withRecent } from "../src/store/views.js"; +import { addDays, daysBetween, enumerateDays } from "../src/sync/window.js"; + +const TODAY = "2026-09-24"; + +const entry = (over: Partial = {}): EntryRow => ({ + path: "/blog/hello/", + collection: "posts", + entryId: "e1", + translationGroup: "e1", + locale: "en", + title: "Hello", + status: "published", + views7: 0, + views30: 0, + updatedAt: "2026-09-24T00:00:00.000Z", + ...over, +}); + +describe("the two windows", () => { + it("cover thirty days between them, with no day in both", () => { + // The recent part is what a paths tick fetches: today - 7 to today. + const recent = enumerateDays(addDays(TODAY, -7), TODAY); + const { since, until } = olderWindow(TODAY); + const older = enumerateDays(since, until); + + expect(recent.length + older.length).toBe(30); + expect(older.filter((day) => recent.includes(day))).toEqual([]); + expect(daysBetween(until, recent[0]!)).toBe(1); + }); +}); + +describe("recentViewsOf", () => { + it("counts seven days for views7 and eight for the recent part", () => { + const rows = enumerateDays(addDays(TODAY, -7), TODAY).map((date) => ({ date, pageviews: 1 })); + expect(recentViewsOf(rows, TODAY)).toEqual({ views7: 7, recent: 8 }); + }); + + it("ignores rows outside the window instead of adding them", () => { + const rows = [ + { date: addDays(TODAY, -8), pageviews: 100 }, + { date: addDays(TODAY, 1), pageviews: 100 }, + { date: TODAY, pageviews: 2 }, + ]; + expect(recentViewsOf(rows, TODAY)).toEqual({ views7: 2, recent: 2 }); + }); +}); + +describe("withRecent", () => { + it("adds today's older part to the fetched recent part", () => { + const next = withRecent(entry({ olderViews: 40, olderDay: TODAY }), { views7: 5, recent: 6 }, TODAY, true); + expect(next).toMatchObject({ views7: 5, recentViews: 6, olderViews: 40, views30: 46 }); + }); + + it("keeps yesterday's older part while today's pass is still running", () => { + const next = withRecent( + entry({ olderViews: 40, olderDay: addDays(TODAY, -1) }), + { views7: 5, recent: 6 }, + TODAY, + false, + ); + expect(next).toMatchObject({ olderViews: 40, views30: 46 }); + }); + + it("zeroes an older part today's finished pass did not reach", () => { + // All of this entry's older traffic was on the day that just left the + // window, so the pass found no rows for it and never wrote it. + const next = withRecent( + entry({ olderViews: 40, olderDay: addDays(TODAY, -1) }), + { views7: 5, recent: 6 }, + TODAY, + true, + ); + expect(next).toMatchObject({ olderViews: 0, olderDay: TODAY, views30: 6 }); + }); + + it("reports no change when nothing moved, so the tick writes nothing", () => { + const settled = entry({ views7: 5, recentViews: 6, olderViews: 40, olderDay: TODAY, views30: 46 }); + expect(withRecent(settled, { views7: 5, recent: 6 }, TODAY, true)).toBeNull(); + }); + + it("repairs a views30 that an older build left at zero", () => { + const legacy = entry({ views7: 5, views30: 0 }); + expect(withRecent(legacy, { views7: 5, recent: 6 }, TODAY, false)).toMatchObject({ views30: 6 }); + }); +}); + +describe("withOlder", () => { + it("writes the day even when the number is unchanged", () => { + const next = withOlder(entry({ olderViews: 40, olderDay: addDays(TODAY, -1), recentViews: 6 }), 40, TODAY); + expect(next).toMatchObject({ olderDay: TODAY, views30: 46 }); + }); + + it("reports no change when the day and the number match", () => { + expect(withOlder(entry({ olderViews: 40, olderDay: TODAY }), 40, TODAY)).toBeNull(); + }); +}); + +describe("foldByPath", () => { + const rows = (path: string, n: number) => Array.from({ length: n }, () => ({ path, pageviews: 2 })); + + it("finishes every path of the last page", () => { + const { finished, carry } = foldByPath([...rows("/a/", 3), ...rows("/b/", 2)], undefined, false); + expect(Object.fromEntries(finished)).toEqual({ "/a/": 6, "/b/": 4 }); + expect(carry).toBeUndefined(); + }); + + it("carries a path that may continue on the next page", () => { + const first = foldByPath([...rows("/a/", 3), ...rows("/b/", 2)], undefined, true); + expect(Object.fromEntries(first.finished)).toEqual({ "/a/": 6 }); + expect(first.carry).toEqual({ path: "/b/", views: 4 }); + + const second = foldByPath([...rows("/b/", 1), ...rows("/c/", 1)], first.carry, false); + expect(Object.fromEntries(second.finished)).toEqual({ "/b/": 6, "/c/": 2 }); + }); + + it("finishes a carry whose path does not continue", () => { + const { finished } = foldByPath(rows("/c/", 1), { path: "/b/", views: 4 }, false); + expect(Object.fromEntries(finished)).toEqual({ "/b/": 4, "/c/": 2 }); + }); + + it("finishes a carry when the next page is empty", () => { + const { finished, carry } = foldByPath([], { path: "/b/", views: 4 }, false); + expect(Object.fromEntries(finished)).toEqual({ "/b/": 4 }); + expect(carry).toBeUndefined(); + }); +}); diff --git a/packages/analytics/tests/widget.test.ts b/packages/analytics/tests/widget.test.ts new file mode 100644 index 0000000..864bd8b --- /dev/null +++ b/packages/analytics/tests/widget.test.ts @@ -0,0 +1,229 @@ +import { validateBlocks } from "@emdash-cms/blocks/server"; +import { describe, expect, it } from "vitest"; + +import type { EntryRow, RollupRow } from "../src/store/rows.js"; +import type { SyncState } from "../src/sync/scheduler.js"; +import { renderWidget, type WidgetInput } from "../src/ui/widget.js"; + +const NOW = new Date("2026-09-20T12:00:00.000Z"); + +const rollup = (date: string, pageviews: number, visits: number, sampleInterval = 1): RollupRow => ({ + date, + pageviews, + visits, + sampleInterval, + fetchedAt: NOW.toISOString(), +}); + +/** Fourteen days ending today, so the previous period exists. */ +function twoWeeks(current: number, previous: number): RollupRow[] { + const rows: RollupRow[] = []; + for (let i = 0; i < 14; i++) { + const day = new Date(NOW.getTime() - i * 86_400_000).toISOString().slice(0, 10); + rows.push(rollup(day, i < 7 ? current : previous, i < 7 ? current : previous)); + } + return rows; +} + +function input(over: Partial = {}): WidgetInput { + return { + state: { phase: "overview", lastSync: "2026-09-20T11:55:00.000Z" }, + rollups: twoWeeks(10, 5), + entriesByPath: new Map(), + now: NOW, + locale: "en", + ...over, + }; +} + +/** + * The real host validator, not a local copy. This is the check that would + * have caught upstream's own `audit-log` bug, where camelCase Block Kit + * keys are accepted by TypeScript and silently ignored by the renderer. + */ +function expectValid(blocks: unknown) { + const result = validateBlocks(blocks); + if (!result.valid) { + throw new Error(`Block Kit validation failed:\n${result.errors.map((e) => ` ${e.path}: ${e.message}`).join("\n")}`); + } + expect(result.valid).toBe(true); +} + +describe("renderWidget produces valid Block Kit", () => { + it("validates with data", () => { + expectValid(renderWidget(input())); + }); + + it("validates with no data at all", () => { + expectValid(renderWidget(input({ rollups: [] }))); + }); + + it("validates with an error on the state", () => { + expectValid( + renderWidget( + input({ + state: { + phase: "overview", + lastSync: "2026-09-20T11:00:00.000Z", + lastError: "Cloudflare rejected the token (403).", + lastErrorAt: "2026-09-20T11:50:00.000Z", + topPaths: [{ path: "/", pageviews: 25, visits: 20 }], + }, + }), + ), + ); + }); + + it("validates a table carrying entry titles", () => { + const entries = new Map([ + [ + "/impressum/", + { + path: "/impressum/", + collection: "pages", + entryId: "e1", + translationGroup: "g1", + locale: "de", + title: "Impressum", + status: "published", + views7: 2, + views30: 9, + updatedAt: NOW.toISOString(), + }, + ], + ]); + expectValid( + renderWidget( + input({ + state: { + phase: "overview", + lastSync: NOW.toISOString(), + topPaths: [ + { path: "/impressum/", pageviews: 2, visits: 2 }, + { path: "/unknown/", pageviews: 1, visits: 1 }, + ], + }, + entriesByPath: entries, + }), + ), + ); + }); +}); + +describe("what the widget says", () => { + it("shows an empty state that explains itself rather than zeroes", () => { + const out = renderWidget(input({ rollups: [], state: { phase: "overview" } })); + const empty = out.find((b) => b.type === "empty"); + expect(empty).toBeDefined(); + expect(JSON.stringify(empty)).toMatch(/first sync has not run/i); + }); + + it("surfaces the last error in the empty state instead of a generic message", () => { + const out = renderWidget( + input({ + rollups: [], + state: { phase: "overview", lastError: "Analytics is not configured yet: add an API token" }, + }), + ); + expect(JSON.stringify(out)).toMatch(/not configured yet/); + }); + + it("points the trend arrow up when the week improved", () => { + const out = renderWidget(input({ rollups: twoWeeks(10, 5) })); + const stats = out.find((b) => b.type === "stats"); + expect(stats).toBeDefined(); + expect(JSON.stringify(stats)).toContain('"trend":"up"'); + }); + + it("omits the trend entirely when there is no earlier period", () => { + // A neutral arrow would assert "unchanged", which on a fresh install + // is a different and false claim. + const out = renderWidget(input({ rollups: [rollup("2026-09-20", 4, 3)] })); + const stats = out.find((b) => b.type === "stats"); + expect(JSON.stringify(stats)).not.toContain('"trend"'); + expect(JSON.stringify(stats)).toMatch(/no earlier period/i); + }); + + it("does not compare against a week that the first-sync backfill only grazes", () => { + // Cloudflare's backfill reaches today - 7, which is the first day of + // the previous week; one day against seven read as a huge jump. + const eightDays = twoWeeks(10, 10).slice(0, 8); + const stats = renderWidget(input({ rollups: eightDays })).find((b) => b.type === "stats"); + expect(JSON.stringify(stats)).not.toContain('"trend"'); + expect(JSON.stringify(stats)).toMatch(/no earlier period/i); + }); + + it("labels sampled numbers as estimates", () => { + const out = renderWidget( + input({ rollups: [rollup("2026-09-19", 10, 10, 10), rollup("2026-09-18", 10, 10, 10)] }), + ); + expect(JSON.stringify(out)).toMatch(/estimated/i); + }); + + it("says today is still counting", () => { + const out = renderWidget(input({ rollups: [rollup("2026-09-20", 5, 5)] })); + expect(JSON.stringify(out)).toMatch(/still counting/i); + }); + + it("falls back to a dash when the join does not know the page", () => { + const out = renderWidget( + input({ + state: { phase: "overview", lastSync: NOW.toISOString(), topPaths: [{ path: "/orphan/", pageviews: 3, visits: 2 }] }, + }), + ); + const table = out.find((b) => b.type === "table"); + expect(table).toBeDefined(); + expect(JSON.stringify(table)).toContain("/orphan/"); + }); + + it("uses snake_case keys the renderer actually reads", () => { + const out = renderWidget( + input({ state: { phase: "overview", lastSync: NOW.toISOString(), topPaths: [{ path: "/", pageviews: 1, visits: 1 }] } }), + ); + const table = out.find((b) => b.type === "table"); + expect(table).toHaveProperty("page_action_id"); + expect(table).toHaveProperty("empty_text"); + expect(table).not.toHaveProperty("pageActionId"); + expect(table).not.toHaveProperty("emptyText"); + + const actions = out.find((b) => b.type === "actions"); + expect(JSON.stringify(actions)).toContain("action_id"); + }); + + it("always offers a refresh button, even with nothing to show", () => { + for (const rollups of [[], twoWeeks(10, 5)]) { + const out = renderWidget(input({ rollups })); + expect(out.some((b) => b.type === "actions")).toBe(true); + } + }); +}); + +describe("locale handling", () => { + it("groups digits the way the reader's locale does", () => { + const big = [rollup("2026-09-20", 1234567, 1234567)]; + const en = JSON.stringify(renderWidget(input({ rollups: big, locale: "en" }))); + const de = JSON.stringify(renderWidget(input({ rollups: big, locale: "de" }))); + expect(en).toContain("1,234,567"); + expect(de).toContain("1.234.567"); + }); + + it("still renders when no locale is attached", () => { + expectValid(renderWidget(input({ locale: undefined }))); + }); +}); + +describe("a finished index that matched nothing", () => { + it("says so and points to the setup check", () => { + const blocks = renderWidget(input({ state: { phase: "overview", lastSync: NOW.toISOString(), indexComplete: true, indexed: 0 } })); + expect(JSON.stringify(blocks)).toMatch(/no page is matched to an entry yet; Check setup on the Analytics page says why/); + }); + + it("stays quiet while the index is still being built or has matches", () => { + for (const state of [ + { phase: "overview" as const, lastSync: NOW.toISOString(), indexComplete: false, indexed: 0 }, + { phase: "overview" as const, lastSync: NOW.toISOString(), indexComplete: true, indexed: 3 }, + ]) { + expect(JSON.stringify(renderWidget(input({ state })))).not.toMatch(/no page is matched/); + } + }); +}); diff --git a/packages/analytics/tests/window.test.ts b/packages/analytics/tests/window.test.ts new file mode 100644 index 0000000..f3e41d9 --- /dev/null +++ b/packages/analytics/tests/window.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import { + addDays, + backfillWindow, + daysBetween, + enumerateDays, + isDay, + isExact, + isFrozen, + isProvisional, + syncWindow, + UNSAMPLED_WINDOW_DAYS, + utcDay, +} from "../src/sync/window.js"; + +const at = (iso: string) => new Date(iso); + +describe("day arithmetic", () => { + it("reads the UTC day, not the local one", () => { + // 23:30 in Munich on the 20th is already the 20th in UTC; 00:30 is + // the 19th. A local-time implementation gets the second one wrong. + expect(utcDay(at("2026-09-20T21:30:00.000Z"))).toBe("2026-09-20"); + expect(utcDay(at("2026-09-20T23:30:00+02:00"))).toBe("2026-09-20"); + expect(utcDay(at("2026-09-21T00:30:00+02:00"))).toBe("2026-09-20"); + }); + + it("crosses month, year and leap-day boundaries", () => { + expect(addDays("2026-03-01", -1)).toBe("2026-02-28"); + expect(addDays("2028-03-01", -1)).toBe("2028-02-29"); + expect(addDays("2026-12-31", 1)).toBe("2027-01-01"); + expect(daysBetween("2026-12-31", "2027-01-01")).toBe(1); + }); + + it("survives the European DST switch", () => { + // 2026-10-25 is the CET/CEST changeover. A local-midnight + // implementation drifts by an hour here and lands on the wrong day. + expect(addDays("2026-10-24", 1)).toBe("2026-10-25"); + expect(addDays("2026-10-25", 1)).toBe("2026-10-26"); + expect(daysBetween("2026-10-24", "2026-10-27")).toBe(3); + }); + + it("counts backwards as negative", () => { + expect(daysBetween("2026-09-20", "2026-09-13")).toBe(-7); + }); + + it("rejects things that are not days", () => { + expect(isDay("2026-09-20")).toBe(true); + expect(isDay("2026-9-20")).toBe(false); + expect(isDay("2026-09-20T00:00:00Z")).toBe(false); + expect(isDay(20260920)).toBe(false); + expect(() => addDays("not-a-day", 1)).toThrow(RangeError); + }); + + it("enumerates inclusively and returns nothing for an inverted range", () => { + expect(enumerateDays("2026-09-18", "2026-09-20")).toEqual(["2026-09-18", "2026-09-19", "2026-09-20"]); + expect(enumerateDays("2026-09-20", "2026-09-20")).toEqual(["2026-09-20"]); + expect(enumerateDays("2026-09-20", "2026-09-18")).toEqual([]); + }); +}); + +describe("syncWindow", () => { + const now = at("2026-09-20T10:00:00.000Z"); + + it("asks for the requested overlap when it is safe", () => { + expect(syncWindow(now, 2)).toEqual({ since: "2026-09-18", until: "2026-09-20", clamped: false }); + }); + + it("never lets date_geq cross the sampling cliff", () => { + // The whole point. A 30-day window would return tenfold-quantized + // numbers for every day in it, today included, so it is refused. + const w = syncWindow(now, 30); + expect(w.since).toBe("2026-09-13"); + expect(w.until).toBe("2026-09-20"); + expect(w.clamped).toBe(true); + expect(daysBetween(w.since, w.until)).toBe(UNSAMPLED_WINDOW_DAYS); + }); + + it("reports honestly whether it clamped", () => { + expect(syncWindow(now, UNSAMPLED_WINDOW_DAYS).clamped).toBe(false); + expect(syncWindow(now, UNSAMPLED_WINDOW_DAYS + 1).clamped).toBe(true); + }); + + it("treats a zero or negative overlap as today only", () => { + expect(syncWindow(now, 0)).toEqual({ since: "2026-09-20", until: "2026-09-20", clamped: false }); + expect(syncWindow(now, -5).since).toBe("2026-09-20"); + }); + + it("backfills exactly to the cliff and no further", () => { + const w = backfillWindow(now); + expect(w.since).toBe("2026-09-13"); + expect(w.clamped).toBe(false); + }); +}); + +describe("freezing", () => { + const today = "2026-09-20"; + + it("treats today as provisional", () => { + // Today reported sampleInterval 2.1-2.3 even in a one-day window, + // so it is an estimate until the day closes. + expect(isProvisional("2026-09-20", today)).toBe(true); + expect(isProvisional("2026-09-19", today)).toBe(false); + }); + + it("never freezes today, whatever the sample interval says", () => { + expect(isFrozen("2026-09-20", today, 1)).toBe(false); + }); + + it("keeps recent closed days writable so late beacons still land", () => { + expect(isFrozen("2026-09-19", today, 1, 2)).toBe(false); + expect(isFrozen("2026-09-18", today, 1, 2)).toBe(true); + }); + + it("refuses to freeze a row that was stored from a sampled response", () => { + // Freezing an estimate would make it permanent and unfixable. + expect(isFrozen("2026-09-15", today, 10, 2)).toBe(false); + expect(isFrozen("2026-09-15", today, 1.35, 2)).toBe(false); + expect(isFrozen("2026-09-15", today, 1, 2)).toBe(true); + }); + + it("calls only sampleInterval 1 exact", () => { + expect(isExact(1)).toBe(true); + expect(isExact(1.0000001)).toBe(false); + expect(isExact(2.33)).toBe(false); + expect(isExact(10)).toBe(false); + }); +}); diff --git a/packages/analytics/tsconfig.json b/packages/analytics/tsconfig.json new file mode 100644 index 0000000..71a62dc --- /dev/null +++ b/packages/analytics/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts", + "tsdown.config.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/analytics/tsdown.config.ts b/packages/analytics/tsdown.config.ts new file mode 100644 index 0000000..7c021ca --- /dev/null +++ b/packages/analytics/tsdown.config.ts @@ -0,0 +1,25 @@ +/** + * Second build: the Astro integration. + * + * `emdash-plugin build` has a hard-coded entry of `src/plugin.ts` and + * produces the sandbox runtime plus the descriptor module. It knows nothing + * about the `./astro` export, and the registry bundle deliberately stages + * only `backend.js`, `manifest.json`, the README and images — so the + * integration has to be built separately and is never part of the bundle a + * sandboxed install downloads. + * + * `platform: "neutral"` because this file runs inside a consumer's Astro + * build, which may be Node, Bun or a Workers-targeted build. + */ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/astro/index.ts"], + outDir: "dist/astro", + format: ["esm"], + outExtensions: () => ({ js: ".mjs" }), + platform: "neutral", + dts: true, + treeshake: true, + clean: false, +}); diff --git a/packages/analytics/vitest.config.ts b/packages/analytics/vitest.config.ts new file mode 100644 index 0000000..6c0855f --- /dev/null +++ b/packages/analytics/vitest.config.ts @@ -0,0 +1,6 @@ +import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [emdashPluginTest()], +}); diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..7e6ae95 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,22 @@ +{ + "name": "@eisbachcode/emdash-plugin-shared", + "version": "0.0.0", + "private": true, + "description": "Internal helpers shared by the Eisbachcode EmDash plugins. Never published — the plugin build inlines it (external: []), so consumers of a published plugin never resolve this package.", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "emdash": ">=0.36.0 <1.0.0" + }, + "devDependencies": { + "emdash": ">=0.36.0 <1.0.0", + "typescript": "^5.9.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..5d87eb0 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,3 @@ +export { clampNumber, parseCollections } from "./values.js"; +export { nextCollectionPage, resetCursors, type CollectionPage, type PluginContentItem } from "./sweep.js"; +export { hasRole, ROLE, type RoleLevel, type RouteUser } from "./roles.js"; diff --git a/packages/shared/src/roles.ts b/packages/shared/src/roles.ts new file mode 100644 index 0000000..6399ab0 --- /dev/null +++ b/packages/shared/src/roles.ts @@ -0,0 +1,48 @@ +/** + * Role levels, and the check a Block Kit route needs before it writes. + * + * Shared because all three plugins have the same shape: one `admin` route + * serving a report an editor should see and a settings form only an + * administrator should submit. A route carries a single permission, so the + * route declares the lower bar and the handler enforces the higher one on + * the write. + */ + +/** + * The numbers EmDash stores for each role. + * + * `UserInfo.role` on a route context is this number and nothing more: the + * plugin API types it as `number`, `emdash/plugin` exports the `Permission` + * type without the `Role` values, and `@emdash-cms/auth` is not a plugin + * dependency. Adding it to get five constants would inline an auth package + * into a bundle with a 384 KiB ceiling. + * + * Copied from `packages/auth/src/types.ts` rather than guessed. They are + * persisted in the user record, so they cannot change without a migration. + */ +export const ROLE = { + SUBSCRIBER: 10, + CONTRIBUTOR: 20, + AUTHOR: 30, + EDITOR: 40, + ADMIN: 50, +} as const; + +export type RoleLevel = (typeof ROLE)[keyof typeof ROLE]; + +/** What a route context carries about the caller. */ +export interface RouteUser { + role?: number; +} + +/** + * Does the caller hold at least `minimum`? + * + * Absent is refused. A public route has no user, and a machine token has no + * bound user, so `undefined` has to mean no rather than unknown: this guards + * a write, and the host has already applied the route's own permission + * before the handler runs. + */ +export function hasRole(user: RouteUser | undefined, minimum: RoleLevel | number): boolean { + return typeof user?.role === "number" && Number.isFinite(user.role) && user.role >= minimum; +} diff --git a/packages/shared/src/sweep.ts b/packages/shared/src/sweep.ts new file mode 100644 index 0000000..deff3b7 --- /dev/null +++ b/packages/shared/src/sweep.ts @@ -0,0 +1,79 @@ +/** + * Cursor-driven collection walk. + * + * Both plugins sweep every configured collection page by page across cron + * ticks rather than in one pass, for the same reason: a sandboxed plugin + * gets 10 subrequests per invocation on Cloudflare and the limit counts + * service-binding calls, so `ctx.content`, `ctx.storage` and `ctx.kv` + * spend from the same budget as an outbound fetch. The limits are fixed — + * the emdash integration exposes no option for them. + * + * The cursor lives in plugin KV, so a tick that dies loses at most its + * own page. + */ + +import type { PluginContext } from "emdash/plugin"; + +/** + * The entry type `ctx.content.list()` hands back. + * + * Derived rather than imported: `emdash/plugin` does not export it, and + * the identically-named `ContentItem` on the package root is the + * *database row* type (`authorId`, `liveRevisionId`, …), which does not + * match. Deriving from the context is exact by construction and cannot + * drift from the runtime shape. + */ +export type PluginContentItem = Awaited< + ReturnType["list"]> +>["items"][number]; + +export interface CollectionPage { + collection: string; + items: PluginContentItem[]; + /** True when this was the last page of that collection. */ + lastPage: boolean; +} + +/** Marker written to the cursor key once a collection is fully walked. */ +const DONE = "done"; + +/** + * Fetch the next unfinished page across `collections`, in order. + * Returns `null` when every collection is walked — the caller's signal + * that a sweep is complete and the cursors can be reset. + */ +export async function nextCollectionPage( + ctx: PluginContext, + collections: string[], + cursorPrefix: string, + pageSize: number, +): Promise { + if (!ctx.content) return null; + + for (const collection of collections) { + const cursorKey = `${cursorPrefix}${collection}`; + const cursor = await ctx.kv.get(cursorKey); + if (cursor === DONE) continue; + + const page = await ctx.content.list(collection, { + limit: pageSize, + ...(cursor ? { cursor } : {}), + }); + + await ctx.kv.set(cursorKey, page.cursor ?? DONE); + return { collection, items: page.items, lastPage: !page.cursor }; + } + + return null; +} + +/** Clear every cursor so the next tick starts a fresh sweep. */ +export async function resetCursors( + ctx: PluginContext, + collections: string[], + cursorPrefix: string, +): Promise { + for (const collection of collections) { + await ctx.kv.delete(`${cursorPrefix}${collection}`); + } +} diff --git a/packages/shared/src/values.ts b/packages/shared/src/values.ts new file mode 100644 index 0000000..0b95c14 --- /dev/null +++ b/packages/shared/src/values.ts @@ -0,0 +1,33 @@ +/** + * Settings value coercion. Shared because every plugin here stores its + * configuration in plugin KV, and KV hands back `unknown`. + */ + +/** + * Parse the comma-separated collection list. + * + * The list is configuration rather than something discovered at runtime + * because a plugin **cannot enumerate collections**: the content API + * takes a collection name on every call and nothing on `PluginContext` + * lists them. + */ +export function parseCollections(raw: unknown): string[] { + if (typeof raw !== "string") return []; + return [...new Set(raw.split(",").map((part) => part.trim()).filter(Boolean))]; +} + +/** + * Coerce a stored number into range, or fall back. + * + * Absent means absent: `ctx.kv.get` answers `null` for an unset key, and + * `Number(null)` is 0 — which would silently clamp to `min` instead of + * using the default. Booleans coerce just as happily, so the accepted + * input types are named rather than inferred. + */ +export function clampNumber(value: unknown, min: number, max: number, fallback: number): number { + if (typeof value !== "number" && typeof value !== "string") return fallback; + if (typeof value === "string" && !value.trim()) return fallback; + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(n))); +} diff --git a/packages/shared/tests/roles.test.ts b/packages/shared/tests/roles.test.ts new file mode 100644 index 0000000..12610df --- /dev/null +++ b/packages/shared/tests/roles.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { hasRole, ROLE } from "../src/roles.js"; + +describe("hasRole", () => { + it("admits the exact role and everything above it", () => { + expect(hasRole({ role: ROLE.ADMIN }, ROLE.ADMIN)).toBe(true); + expect(hasRole({ role: ROLE.EDITOR }, ROLE.EDITOR)).toBe(true); + expect(hasRole({ role: ROLE.ADMIN }, ROLE.EDITOR)).toBe(true); + }); + + it("refuses everything below it", () => { + expect(hasRole({ role: ROLE.EDITOR }, ROLE.ADMIN)).toBe(false); + expect(hasRole({ role: ROLE.AUTHOR }, ROLE.EDITOR)).toBe(false); + expect(hasRole({ role: ROLE.SUBSCRIBER }, ROLE.CONTRIBUTOR)).toBe(false); + }); + + it("refuses an absent user rather than treating it as unknown", () => { + // A public route has no user and a machine token has no bound user. + // This guards a write, so absent has to mean no. + expect(hasRole(undefined, ROLE.ADMIN)).toBe(false); + expect(hasRole({}, ROLE.ADMIN)).toBe(false); + }); + + it("refuses a role that is not a usable number", () => { + expect(hasRole({ role: Number.NaN }, ROLE.SUBSCRIBER)).toBe(false); + expect(hasRole({ role: "50" as unknown as number }, ROLE.ADMIN)).toBe(false); + }); + + it("orders the levels the way EmDash does", () => { + // Guards against a transcription slip: the order is what every + // comparison above depends on. + expect([ROLE.SUBSCRIBER, ROLE.CONTRIBUTOR, ROLE.AUTHOR, ROLE.EDITOR, ROLE.ADMIN]).toEqual([10, 20, 30, 40, 50]); + }); +}); diff --git a/packages/shared/tests/sweep.test.ts b/packages/shared/tests/sweep.test.ts new file mode 100644 index 0000000..7b43ff7 --- /dev/null +++ b/packages/shared/tests/sweep.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { nextCollectionPage, resetCursors } from "../src/sweep.js"; + +const PREFIX = "state:cursor:"; + +describe("nextCollectionPage", () => { + it("walks a collection page by page, parking the cursor in KV", async () => { + const store: Record = {}; + const ctx = fakeCtx(store, { + posts: [ + { items: [entry("a")], cursor: "c1" }, + { items: [entry("b")], cursor: null }, + ], + }); + + const first = await nextCollectionPage(ctx, ["posts"], PREFIX, 1); + expect(first).toMatchObject({ collection: "posts", lastPage: false }); + expect(store[`${PREFIX}posts`]).toBe("c1"); + + const second = await nextCollectionPage(ctx, ["posts"], PREFIX, 1); + expect(second).toMatchObject({ collection: "posts", lastPage: true }); + expect(store[`${PREFIX}posts`]).toBe("done"); + }); + + it("moves on to the next collection and finally returns null", async () => { + const store: Record = { [`${PREFIX}posts`]: "done" }; + const ctx = fakeCtx(store, { pages: [{ items: [entry("x")], cursor: null }] }); + + expect(await nextCollectionPage(ctx, ["posts", "pages"], PREFIX, 10)).toMatchObject({ + collection: "pages", + }); + expect(await nextCollectionPage(ctx, ["posts", "pages"], PREFIX, 10)).toBeNull(); + }); + + it("is inert without the content capability", async () => { + const ctx = { content: undefined } as unknown as import("emdash/plugin").PluginContext; + expect(await nextCollectionPage(ctx, ["posts"], PREFIX, 10)).toBeNull(); + }); +}); + +describe("resetCursors", () => { + it("clears every configured collection", async () => { + const store: Record = { + [`${PREFIX}posts`]: "done", + [`${PREFIX}pages`]: "c9", + }; + await resetCursors(fakeCtx(store, {}), ["posts", "pages"], PREFIX); + expect(store).toEqual({}); + }); +}); + +function entry(id: string) { + return { id, type: "posts", slug: id, status: "published", locale: null, data: {}, createdAt: "", updatedAt: "", publishedAt: null }; +} + +function fakeCtx( + store: Record, + pages: Record[]; cursor: string | null }>>, +) { + const served: Record = {}; + return { + kv: { + get: async (key: string) => store[key] ?? null, + set: async (key: string, value: unknown) => { + store[key] = value; + }, + delete: async (key: string) => delete store[key], + list: async () => [], + }, + content: { + list: async (collection: string) => { + const index = served[collection] ?? 0; + served[collection] = index + 1; + const page = pages[collection]?.[index] ?? { items: [], cursor: null }; + return { items: page.items, cursor: page.cursor }; + }, + get: async () => null, + }, + } as unknown as import("emdash/plugin").PluginContext; +} diff --git a/packages/shared/tests/values.test.ts b/packages/shared/tests/values.test.ts new file mode 100644 index 0000000..85cfa70 --- /dev/null +++ b/packages/shared/tests/values.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { clampNumber, parseCollections } from "../src/values.js"; + +describe("parseCollections", () => { + it("splits, trims, drops blanks and duplicates", () => { + expect(parseCollections(" posts , pages ,, posts ")).toEqual(["posts", "pages"]); + }); + + it("returns nothing for a non-string", () => { + expect(parseCollections(undefined)).toEqual([]); + expect(parseCollections(["posts"])).toEqual([]); + }); +}); + +describe("clampNumber", () => { + it("clamps, truncates and falls back", () => { + expect(clampNumber(9999, 1, 500, 25)).toBe(500); + expect(clampNumber(0, 1, 500, 25)).toBe(1); + expect(clampNumber(3.7, 1, 500, 25)).toBe(3); + expect(clampNumber("nonsense", 1, 500, 25)).toBe(25); + }); + + it("falls back for an absent value rather than clamping to min", () => { + // ctx.kv.get answers null for an unset key. Number(null) is 0. + expect(clampNumber(null, 1, 500, 25)).toBe(25); + expect(clampNumber(undefined, 1, 500, 25)).toBe(25); + expect(clampNumber("", 1, 500, 25)).toBe(25); + expect(clampNumber(true, 1, 500, 25)).toBe(25); + }); +}); diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..0bca85d --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..989fa5e --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,8361 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^3.0.3 + version: 3.0.3 + + packages/analytics: + devDependencies: + '@eisbachcode/emdash-plugin-shared': + specifier: workspace:* + version: link:../shared + '@emdash-cms/blocks': + specifier: 0.39.0 + version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/plugin-cli': + specifier: 0.12.0 + version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@emdash-cms/plugin-test': + specifier: 0.2.0 + version: 0.2.0(59949aeb10c26a9714a29cfa8eddca48) + emdash: + specifier: '>=0.39.0 <1.0.0' + version: 0.39.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) + tsdown: + specifier: 0.20.3 + version: 0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@5.9.3) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + + packages/shared: + devDependencies: + emdash: + specifier: '>=0.36.0 <1.0.0' + version: 0.39.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + +packages: + + '@astrojs/cloudflare@14.3.3': + resolution: {integrity: sha512-kYb1xf+BBa8oAIMSZPfXxZWVWSEDhA+AgBN6ClDDDZspIN1M2XsIC6/fozyuCApNuiyqa3eUQbai62WohqJUgQ==} + peerDependencies: + astro: ^7.2.0 + wrangler: ^4.125.0 + + '@astrojs/compiler-binding-android-arm64@0.5.0': + resolution: {integrity: sha512-wVviC/1KaBqGVF0DbTZ8UtAE3c8TN84CCe7w4ju3SGzo/ByWKn8HCqSU/+1wdltGCCSa30pjVk3H8DQUgnfhRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.5.0': + resolution: {integrity: sha512-5NNIkaGeiztD9QQfhOy+nNrBCZVeHqVZPlfJVjTKq4yJiiSsxAhuBzmVlmK3BDhbSgUT8dai4JV5m6t4/BIQaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.0': + resolution: {integrity: sha512-HU/waaccOQDXpvjoWA/9xzadNZIcWKblJoaw19lCxfVs6AHQYdZZOzZUQjY8HaLYGerTXjdADbra2XU+kXk9Og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.0': + resolution: {integrity: sha512-a21petBJRi0mNBf+gkRZFoWdGH+VOo0zRQSzM4Nuez5RL5rFFkmbondMejkaRZzvE3LVYU2NVD5MGRgAs4V+nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.0': + resolution: {integrity: sha512-t5rmu23b/6e6v0bhPibCtwPdABXMRL+bUaJXdkJwTpkINp4zCgH6fNa5Z6+RgkgFSCpmg9uf1MtRIqx1gdmBaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.0': + resolution: {integrity: sha512-JWtouuxSiDb2OKKFsxOwfqlpJuZHo1ExirlBGfDgoJVG5zeJWwcd2tU2w4UaJA4suT/tAFSLk/vFhM5fLjWV4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.0': + resolution: {integrity: sha512-LlaSO5v3UC6IxuTKXDViWV/fFHYhLKSO9Of118lNBXv/sjIWwziU+jjENxPgxfq/4oUeOu+613y6H93LBPNTOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.0': + resolution: {integrity: sha512-IP+Cp0m9yKg/AUT47077vHEaAOh/oT/iUZx1PDnV2GJzUxLSEotdQy5Hhv+ycvEadO/DbETSJfAWeV7kte4etQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.0': + resolution: {integrity: sha512-v6plwy5GQBGvfv4QgiGhy/qkkQvYKW7Zh2vBArOnsGEDNHyUS43CWYwIQdw4ZdVVmLsFyhwTiueDZEP6zejddQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.0': + resolution: {integrity: sha512-SgM158cSCJ248UIXaCCW2WqSTjOoFlDv93ed/ATguJqYpZNNVMlOdcPlgNH7WYj/OYeD0rIQr7XyRAYhB7+G1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.5.0': + resolution: {integrity: sha512-PxFTx3WoxQnbgUvvwmF6LRQwhHO77a4pewOtJvzxlrYtQ90tLgomLKvks/akrd2iix2ngGlOhxQonKNd8YoBHg==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.5.0': + resolution: {integrity: sha512-2xIypn5R33FRWCXXXv0sfbDj8xcOvc2VmIbteytxMjOzP9LiGfvqaAFJjhdAjzhs6+ZKzYzbSE79rrPObYW5mg==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.11.0': + resolution: {integrity: sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA==} + + '@astrojs/markdown-satteri@0.4.2': + resolution: {integrity: sha512-g4QehXnB/MzxQWnSf17NciFInMqEJy9948p8cc7zGg/r7995m0Ln5PMr3Xlnj24iey1LchaoZ3SzTOa3D4P/3w==} + + '@astrojs/prism@4.0.2': + resolution: {integrity: sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==} + engines: {node: '>=22.12.0'} + + '@astrojs/react@7.0.0': + resolution: {integrity: sha512-BUs2jVvuUnPBA/vtp9y1PvvoYeZZtjmwphu0TJX0wSQ6xzR31/OyflQ9Gmm4rn9IRZNFObCJG7wWsZSqr4OMUA==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@types/react': ^17.0.50 || ^18.0.21 || ^19.0.0 + '@types/react-dom': ^17.0.17 || ^18.0.6 || ^19.0.0 + oxc-transform-react: ^0.145.0 + react: ^17.0.2 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + oxc-transform-react: + optional: true + + '@astrojs/telemetry@3.3.3': + resolution: {integrity: sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/underscore-redirects@1.0.4': + resolution: {integrity: sha512-sD3bn7i2yC+ocxlhCTshdg3gUM471Nag3wgyHilA+kpmufokvmUu1p5Mw5GT5Xp8SPC1Nlq0BJAgV/lslnWvRw==} + + '@atcute/atproto@4.0.4': + resolution: {integrity: sha512-aEp5wQVPVbZcFJUwoi54tyT+ehRI/xtPe9T0zIvcryHAVXF/zxHtNboM+htbZLOAGsDFsqzgCyxph0Bafw7L8w==} + peerDependencies: + '@atcute/lexicons': ^2.0.0 + + '@atcute/car@6.1.0': + resolution: {integrity: sha512-Dp7MlyI3YT7NiIUK6bm2ZyA95Pdun+DuFlcr40ZsGdqhOkKrctZT7wMZXSs6IYd7RorjSnddEJDh51hqrkNSPQ==} + peerDependencies: + '@atcute/cbor': ^2.0.0 + '@atcute/cid': ^2.0.0 + + '@atcute/cbor@2.3.8': + resolution: {integrity: sha512-gazXBcNTr2U3cRaCjfRbq2GKHJnWapUUA3mgehL+0tbVXEty2wf/LIwF9z46gjqjX2DYGGwkybF8lTI3dzHaNQ==} + peerDependencies: + '@atcute/cid': ^2.5.0 + + '@atcute/cid@2.5.0': + resolution: {integrity: sha512-CAZyzUdaQhuC+S7tGjoC7vgyDI2X4JU+CPO/7WjEU7HlNsAGXjSkD++5TlfnSWGDqUeqpjMZjUfFBZYHKzPHew==} + + '@atcute/client@5.1.2': + resolution: {integrity: sha512-V3MOJudmmbuiFfrmLR3/ZGA+PKcI4E4ZNEDGKMX/YrvFrHyElD1/gb8NxLesrlIcHefiulqfX9dAl6hOkIt7CA==} + peerDependencies: + '@atcute/lexicons': ^2.0.0 + + '@atcute/crypto@2.4.4': + resolution: {integrity: sha512-Yc7lXz4ndDjbs+/WrKeGS+sVEr+sx8xi5zSVInKNl0FAEY6KbvcT+bjaU9A3erUVviFp9yEcu/1aSg/f+GPpBg==} + + '@atcute/identity-resolver@2.0.2': + resolution: {integrity: sha512-NPavjKNRfunkMD99HjjKhEtyG76873V/N9IwwC1n+ePUW8wqTHSXstPLtGCelGBE1pA0T6RITzvbfDnMfOaCrw==} + peerDependencies: + '@atcute/identity': ^2.0.0 + '@atcute/lexicons': ^2.0.0 + + '@atcute/identity@2.0.2': + resolution: {integrity: sha512-amr/EQceqVtBVmjBK4uUF7nKKYuRttadigpvOcAn4dnO6SNSwSjQi8KDH9LnukEotPsSP4UDsQvNfHWEoUcslw==} + peerDependencies: + '@atcute/lexicons': ^2.0.0 + + '@atcute/lexicons@2.1.1': + resolution: {integrity: sha512-kHyqUW8g/Fq9LTctGh0TVBx0AmtecV6VSrRprrSbqKqHe9q8C6y+YPL3EMNq2l93XA1iDHkkvYwLdTcKL/lNbA==} + + '@atcute/mst@1.1.1': + resolution: {integrity: sha512-QWoW69Pg5RWrc0B98Cp5K4UjeXI+2vzBrWmoBUW4nSR51LsamOnG9HsWovcOoCRhnj1ZYAMlnAc5Eh8+gJFoSA==} + peerDependencies: + '@atcute/cbor': ^2.0.0 + '@atcute/cid': ^2.5.0 + + '@atcute/multibase@1.2.5': + resolution: {integrity: sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==} + + '@atcute/oauth-crypto@1.0.1': + resolution: {integrity: sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==} + + '@atcute/oauth-keyset@0.1.3': + resolution: {integrity: sha512-VSNUb2TLUTyntgzfn58kwNL4kp4TD+M868tyyrFWlCaDVy0pDnVFfUXa1z1W+DBWLL9ICy2qTdPitRokAZKivw==} + + '@atcute/oauth-node-client@2.0.1': + resolution: {integrity: sha512-MgqN9Rnt5nuUH5zriETtbQrwcx/tWaELKaFVQtqggMtRI4k4IP5+ZbnG40tiJjbZfSclapA2kwOnT14kO0nAxg==} + peerDependencies: + '@atcute/identity-resolver': ^2.0.0 + '@atcute/lexicons': ^2.0.0 + + '@atcute/oauth-types@1.0.1': + resolution: {integrity: sha512-9uFP/nK0PgPDsaNbLArzrCHvTZO1k04cSEX5jZ75W2Jh7Jxbh/CSTenSrad3CZXuX6y6hDquEtl6H/IqLtTmHw==} + + '@atcute/repo@1.1.0': + resolution: {integrity: sha512-WXOh05E/NT39oqkNm3xIit4ysJIZxmkQ1GWOkwDNvTYIDW0CYqwThQ+837r72/Tr490E1Zvz09xOOjtx/XmlhA==} + peerDependencies: + '@atcute/cbor': ^2.0.0 + '@atcute/cid': ^2.5.0 + '@atcute/lexicons': ^2.0.0 + + '@atcute/uint8array@1.2.0': + resolution: {integrity: sha512-KoBGTbV4lS8zXNu91osM3FecrH3NUnxNPsBAkaNtn6uf/p240245qoLn7keFpRM0npX522X8R+XBBb2rvtE7Rg==} + + '@atcute/util-fetch@2.0.2': + resolution: {integrity: sha512-I0oenHlJjwRpWPyAJojox5H0z9NthhdMFaEjtkxyjo85VPPxk1vMJk/1ZjMJsrs1SgHhB3spazEWzV5DW8w70A==} + + '@atcute/util-text@1.3.4': + resolution: {integrity: sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ==} + + '@atcute/varint@2.0.2': + resolution: {integrity: sha512-/+hS1juMgnmf6eL6lICUkTw7wcGTo3I+Q0L1PI521mUz77rGSC6nXAUNKtvm2wYJpuWdEGq+GILGoYkOArn0TQ==} + + '@babel/generator@8.0.0-rc.2': + resolution: {integrity: sha512-oCQ1IKPwkzCeJzAPb7Fv8rQ9k5+1sG8mf2uoHiMInPYvkRfrDJxbTIbH51U+jstlkghus0vAi3EBvkfvEsYNLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.0-rc.2': + resolution: {integrity: sha512-xExUBkuXWJjVuIbO7z6q7/BA9bgfJDEhVL0ggrggLMbg0IzCUWGT1hZGE8qUH7Il7/RD/a6cZ3AAFrrlp1LF/A==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.0-rc.2': + resolution: {integrity: sha512-29AhEtcq4x8Dp3T72qvUMZHx0OMXCj4Jy/TEReQa+KWLln524Cj1fWb3QFi0l/xSpptQBR6y9RNEXuxpFvwiUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + '@babel/parser@8.0.6': + resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.0-rc.2': + resolution: {integrity: sha512-91gAaWRznDwSX4E2tZ1YjBuIfnQVOFDCQ2r0Toby0gu4XEbyF623kXLMA8d4ZbCu+fINcrudkmEcwSUHgDDkNw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@base-ui/react@1.8.0': + resolution: {integrity: sha512-P0/1sxo6SBVZOklKMIedvTWqw2s2IQzi9x5bIVsXu980cuSOD4NeuRSs+/L7LZQfDkZP/uRZyGPyfFl/B1oH+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.4.0': + resolution: {integrity: sha512-bO9fz25kKtPf+aZVyfQrC0PDmJdmVni31W2hCS5/Owb+inwdIL3XU26pCPRPlt4LSxZrBgLwubXQXQlKaFEZzw==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@changesets/apply-release-plan@8.1.1': + resolution: {integrity: sha512-nk2Hbq3M9NeHMdaIRBDvawKrQJzXB9sGyBUE5yWnIKtvdcNCPj75zBekJYr0gTT2ecJH3eFeTrc8eisPImcv0Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/assemble-release-plan@7.0.0': + resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/changelog-git@1.0.0': + resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/cli@3.0.3': + resolution: {integrity: sha512-L1i67bMfkK6iC/kDejuNO4pFgTRcWIObsD6Qof2woP1I4M8tj17DGlcpRaTzTv1DYSP5EMwWdEtR8X0Pj5Jg3Q==} + engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'} + hasBin: true + + '@changesets/config@4.0.1': + resolution: {integrity: sha512-PxpxSQLQSLDjceAgRq+4FWlU+HflDIw03mmPVipxoQx9xrXFUjmqTy3SE+n/2t+DqPdpPiLWVr5NRiLZvjyLwg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/errors@1.0.0': + resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/format@0.1.2': + resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/get-dependents-graph@3.0.0': + resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/git@4.0.1': + resolution: {integrity: sha512-6vWpIAC4LkpmlqaIu37ViT5enyd9+uBwAiGqCGhASvkSHBgx4PrtTGXWTMFYpDmZbjgyonyVgMciPrW4MqtJsA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/parse@1.0.0': + resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/pre@3.0.0': + resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/read@1.0.1': + resolution: {integrity: sha512-nzvSC6RcTiWf/dsuwZFXpLzGGsRHYCL68U3uHV7srsulU93aVWY7u2GEJiDZ+4GIIElfaW5EqtKC0UHroY+LTQ==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/should-skip-package@1.0.0': + resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/types@7.0.0': + resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==} + engines: {node: ^22.11 || ^24 || >=26} + + '@changesets/write@1.0.1': + resolution: {integrity: sha512-q/ThtP9gcnEP6xlv7LrY26C2mHqdGBti/MmOVngt/M/oIGYkssmQGxPK9WzBNt2juVcH/vml2WQ+ra8LXYOTaA==} + engines: {node: ^22.11 || ^24 || >=26} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@cloudflare/ai-search-snippet@0.0.42': + resolution: {integrity: sha512-LSNCvszcgEb+hgH1jpKCGaBrXScbahpjFVoXuDGiUyC9Td7hDR57+YdMMcBbhDa7S4GYaoCO1aziJYjvC0fwnw==} + engines: {node: '>=16.0.0'} + + '@cloudflare/kumo@2.6.0': + resolution: {integrity: sha512-rcUUvhrtxI0veNJZLgKVSnxH/L0M48jtc4UoNhvu0l0RiRmjHORFTBYq/phFQyt7JGG3s98QPZc5w1eW+UQIOg==} + hasBin: true + peerDependencies: + '@phosphor-icons/react': ^2.1.10 + echarts: ^6.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + zod: ^4.0.0 + peerDependenciesMeta: + echarts: + optional: true + zod: + optional: true + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vite-plugin@1.59.0': + resolution: {integrity: sha512-OpG8HdJgzpo360i7lDGS0cF4E94+AKmUYIGglYNwlMUVGORxhZBNbgwMYBdXGxp5GortD6SJjgSi96G9AgR8jA==} + hasBin: true + peerDependencies: + vite: ^6.1.0 || ^7.0.0 || ^8.0.0 + wrangler: ^4.138.0 + + '@cloudflare/vitest-plugin@1.2.5': + resolution: {integrity: sha512-McfN3960DTIquDbkgch/wFSfPuWfafR7qSRv5DuzHKNRGm3GlPsZ8W6UM0QUcx2Mp+8hywfr1WOfR+dXnAyGbQ==} + peerDependencies: + '@vitest/runner': ^4.1.0 + '@vitest/snapshot': ^4.1.0 + vitest: ^4.1.0 + + '@cloudflare/workerd-darwin-64@1.20260921.1': + resolution: {integrity: sha512-3iB2WnYOlZ29T+1zhCwbHFExCBp6E9bgmDUMryATYwrIGEQ1YbvR78m4ydm56XKN/d/yF3803ivMGfZMYDtiMg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260921.1': + resolution: {integrity: sha512-FpqVR7IQXVBmGtajyonEmhmb5UAsmV7dTaIkpemmHZXHEw7uYpkhkzKPjc4BOPhNQy8iwt2p+RZBPMY3Y7/bvQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260921.1': + resolution: {integrity: sha512-riAJIohaVp5A8Sqy4yKlzHOaLPOICMf5oey+jC2rm45RVT+wK8+7UU0d31Dy/02Nc8YUkobAFwNVjX06P8WQ5g==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260921.1': + resolution: {integrity: sha512-tnJu08tT7s0XWDqp3O0H/vCp0voy9OqVAzspb89biMo1dh8IiEpnyXnoPmdJ7H4qBnXCmXgy0kuEphuvpDPj9w==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260921.1': + resolution: {integrity: sha512-VgNcRPstoZMb1G94JTrx+jU24GtkkazNfox0gnF/2fkuXpcfW/M0e0xvdMovYfwt8ZxG5AB2ZNvanD6ufBwiuQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@5.20260924.1': + resolution: {integrity: sha512-puvr63PrfAFIkdh3FInGEgS0VTQ49WYNYDC2Eupd+qvwWwGpSRjxJD5Iz22oNHCmV1UEcsDz+OtGF++01vUloA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emdash-cms/admin@0.39.0': + resolution: {integrity: sha512-eAWpYU8YgdONCKyk/WiNx4o+kz3syiXlhcIoT2joYOqpiyZReviCjpl/97AdkosvzNNvnjTcaM3ar0x9FLEc2w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@emdash-cms/admin@0.39.1': + resolution: {integrity: sha512-UmspPWjfJved4T1rwLnct4VfQRWMMXd0FSJ1MWKgIc2pnhwgxcOkl/+B5QFvWQULtqPXc3QFajfpCk0z/W5AQA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@emdash-cms/auth@0.39.0': + resolution: {integrity: sha512-13LmRSfkgdAl3NCpzd7Fsl1WSXmaGDiIAooaOXs5O/hSocaP3BQR59N1tLWT1Ibkd0dz2rzIuibFIu+fGHH+8A==} + peerDependencies: + astro: '>=6.0.0-beta.0' + kysely: ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@emdash-cms/auth@0.39.1': + resolution: {integrity: sha512-Ae5gq30YSexjTuG9xqZ7xA8IhBnJzSOVXIu64tu506O7gq9XEJhoZiS9YiSxRVCi6cZ/ZUBYVqHrP4RrJJmpMA==} + peerDependencies: + astro: '>=6.0.0-beta.0' + kysely: ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@emdash-cms/blocks@0.39.0': + resolution: {integrity: sha512-rXUAvjhxVJflkXdz4mW210TMC7zS5ueGd7BxTOhPKGVUiDNsWKzTwaOrPTP/wDX1JdYZbm8xeeRgGclZSHyqkA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@emdash-cms/blocks@0.39.1': + resolution: {integrity: sha512-hxgt4xDcFReS+i5HtR/sIwl92g9ChhCaOmcdGpKBYcDQJrUtkQRYuwBLWYKidNRijUXQz9Ym3L4qXfKauNg9Hw==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + '@emdash-cms/cloudflare@0.39.0': + resolution: {integrity: sha512-srdTVyTiI4RRdPfRKgryaX1af3tmNNyMn33yP/XAYet7jNMH+feBr6TNhAHNeLXTHqLD00lmRJ8hyDDyLoFApA==} + peerDependencies: + '@astrojs/cloudflare': '>=12.0.0' + '@cloudflare/kumo': 2.6.0 + '@cloudflare/workers-types': '>=4.0.0' + '@phosphor-icons/react': ^2.1.10 + astro: '>=6.0.0' + kysely: '>=0.28.17' + pg: '>=8.16.3' + react: ^18.0.0 || ^19.0.0 + wrangler: '>=4.99.0' + peerDependenciesMeta: + '@cloudflare/kumo': + optional: true + '@phosphor-icons/react': + optional: true + pg: + optional: true + react: + optional: true + + '@emdash-cms/gutenberg-to-portable-text@0.39.0': + resolution: {integrity: sha512-ZGqJI52AyL0AvCBJi9pXsrJGeNWeH4kAJJTV9QA1kcbKThkiGD1fPfX5sChkgbDzUZsLodWKEYtEFpIdbgbeGw==} + + '@emdash-cms/gutenberg-to-portable-text@0.39.1': + resolution: {integrity: sha512-2aBrOWavuNrr6W2fss0d4BQKkvV9e7cqmJHQEbN0HHhI0Xpr9w0VZHaZ2rzRQuTwa+Eb0GpIBWpCU1riFzns3Q==} + + '@emdash-cms/plugin-cli@0.12.0': + resolution: {integrity: sha512-p8nYyx4IiaBe3p6mWiFzgdraql42+bWVlyLe0vh0fTPVkyVdXzhf+nkrd1amltDjIs1r7uvHxAT94JPRK4ih1Q==} + hasBin: true + + '@emdash-cms/plugin-test@0.2.0': + resolution: {integrity: sha512-waI6SeWdHWkwoXkM/bx3QmY0PiioC1+MLPG3bgA5lgk1V+IgA0+yQWoOs351RlYoC2h4O+WlGg0ycb/vhpka5A==} + peerDependencies: + vite: ^8.0.0 + vitest: ^4.1.0 + + '@emdash-cms/plugin-types@0.4.0': + resolution: {integrity: sha512-HqYiU8xL/uTalcPHcVI5K/n3Z1joa1WpvI0/itG+RmJwMWGHLv5ATmo9OeChczjfb31orJ4wKfFQIEXbNxrsKA==} + + '@emdash-cms/registry-client@0.6.1': + resolution: {integrity: sha512-F2YWxL3lFJVEEMk3x4cv0WaCSfK0iJ7ylT+/UiLFVtS+ETzSBnqe/F0ojE2I06TKQ7MLfyNGiT/kfInheMhrkA==} + + '@emdash-cms/registry-lexicons@0.6.0': + resolution: {integrity: sha512-wLkiE4gi4ZlrRdnonpWLOkgxUCxqae6jlu8CLkJZbwVTEPsbDoAmCrG2+PZ4rQ6YqBSq36ax+OptjBxiXPW15w==} + + '@emdash-cms/registry-moderation@0.2.0': + resolution: {integrity: sha512-NVth5+jX85f2ZtIS8UcCbXwpxylQUeQMaem3931u6uCGzq/zdHQ0Ffm52X0TaCHd0HBzOSbDX8eIqCQYkJbDxg==} + + '@emdash-cms/registry-verification@0.3.2': + resolution: {integrity: sha512-7ezV2qndCxlbmZYTRHyQxK3cUNchOgtTYc6CjppwX2NfuKHTaE5HS3obmTnKB1THCZHTsgDK6bnn7DK1SvQEwA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.20': + resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@libsql/client@0.8.1': + resolution: {integrity: sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA==} + + '@libsql/core@0.8.1': + resolution: {integrity: sha512-u6nrj6HZMTPsgJ9EBhLzO2uhqhlHQJQmVHV+0yFLvfGf3oSP8w7TjZCNUgu1G8jHISx6KFi7bmcrdXW9lRt++A==} + + '@libsql/darwin-arm64@0.3.19': + resolution: {integrity: sha512-rmOqsLcDI65zzxlUOoEiPJLhqmbFsZF6p4UJQ2kMqB+Kc0Rt5/A1OAdOZ/Wo8fQfJWjR1IbkbpEINFioyKf+nQ==} + cpu: [arm64] + os: [darwin] + + '@libsql/darwin-x64@0.3.19': + resolution: {integrity: sha512-q9O55B646zU+644SMmOQL3FIfpmEvdWpRpzubwFc2trsa+zoBlSkHuzU9v/C+UNoPHQVRMP7KQctJ455I/h/xw==} + cpu: [x64] + os: [darwin] + + '@libsql/hrana-client@0.6.2': + resolution: {integrity: sha512-MWxgD7mXLNf9FXXiM0bc90wCjZSpErWKr5mGza7ERy2FJNNMXd7JIOv+DepBA1FQTIfI8TFO4/QDYgaQC0goNw==} + + '@libsql/isomorphic-fetch@0.2.5': + resolution: {integrity: sha512-8s/B2TClEHms2yb+JGpsVRTPBfy1ih/Pq6h6gvyaNcYnMVJvgQRY7wAa8U2nD0dppbCuDU5evTNMEhrQ17ZKKg==} + engines: {node: '>=18.0.0'} + + '@libsql/isomorphic-ws@0.1.5': + resolution: {integrity: sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg==} + + '@libsql/kysely-libsql@0.4.1': + resolution: {integrity: sha512-mCTa6OWgoME8LNu22COM6XjKBmcMAvNtIO6DYM10jSAFq779fVlrTKQEmXIB8TwJVU65dA5jGCpT8gkDdWS0HQ==} + peerDependencies: + kysely: '*' + + '@libsql/linux-arm64-gnu@0.3.19': + resolution: {integrity: sha512-mgeAUU1oqqh57k7I3cQyU6Trpdsdt607eFyEmH5QO7dv303ti+LjUvh1pp21QWV6WX7wZyjeJV1/VzEImB+jRg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-arm64-musl@0.3.19': + resolution: {integrity: sha512-VEZtxghyK6zwGzU9PHohvNxthruSxBEnRrX7BSL5jQ62tN4n2JNepJ6SdzXp70pdzTfwroOj/eMwiPt94gkVRg==} + cpu: [arm64] + os: [linux] + + '@libsql/linux-x64-gnu@0.3.19': + resolution: {integrity: sha512-2t/J7LD5w2f63wGihEO+0GxfTyYIyLGEvTFEsMO16XI5o7IS9vcSHrxsvAJs4w2Pf907uDjmc7fUfMg6L82BrQ==} + cpu: [x64] + os: [linux] + + '@libsql/linux-x64-musl@0.3.19': + resolution: {integrity: sha512-BLsXyJaL8gZD8+3W2LU08lDEd9MIgGds0yPy5iNPp8tfhXx3pV/Fge2GErN0FC+nzt4DYQtjL+A9GUMglQefXQ==} + cpu: [x64] + os: [linux] + + '@libsql/win32-x64-msvc@0.3.19': + resolution: {integrity: sha512-ay1X9AobE4BpzG0XPw1gplyLZPGHIgJOovvW23gUrukRegiUP62uzhpRbKNogLlUOynyXeq//prHgPXiebUfWg==} + cpu: [x64] + os: [win32] + + '@lingui/core@5.9.5': + resolution: {integrity: sha512-Y+iZq9NqnqZOqHNgPomUFP21KH/zs4oTTizWoz0AKAkBbq9T9yb1DSz/ugtBRjF1YLtKMF9tq28v3thMHANSiQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@lingui/babel-plugin-lingui-macro': 5.9.5 + babel-plugin-macros: 2 || 3 + peerDependenciesMeta: + '@lingui/babel-plugin-lingui-macro': + optional: true + babel-plugin-macros: + optional: true + + '@lingui/message-utils@5.9.5': + resolution: {integrity: sha512-t3dNbjb1dWkvcpXGMXIEyBDO3l4B8J2ColZXi0NTG1ioAj+sDfFxFB8fepVgd3JAk+AwARlOLvF14oS0mAdgpw==} + engines: {node: '>=20.0.0'} + + '@lingui/react@5.9.5': + resolution: {integrity: sha512-jzYoA/f4jrTfpOB+jrMhlC835UwqSXJdepr7cfWsmg+Rpp3HBSREtfrogaz1LqLI/AVnkmfp10Mo6VOp/8qeOQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@lingui/babel-plugin-lingui-macro': 5.9.5 + babel-plugin-macros: 2 || 3 + react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@lingui/babel-plugin-lingui-macro': + optional: true + babel-plugin-macros: + optional: true + + '@manypkg/find-root@3.1.0': + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} + + '@manypkg/get-packages@3.1.0': + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} + + '@manypkg/tools@2.1.2': + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} + + '@messageformat/parser@5.1.1': + resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==} + + '@modelcontextprotocol/sdk@1.30.1': + resolution: {integrity: sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@neon-rs/load@0.0.4': + resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} + + '@noble/secp256k1@3.2.0': + resolution: {integrity: sha512-Z3ZAWOTxJ0EuTuZTi7Y69iK7GgrLHh8sgm45lVDhg/b3Nk1TpAiKhick2KkZisHuupeepSkyIydN/J459SdX1w==} + + '@oomfware/eval@0.1.0': + resolution: {integrity: sha512-EIkukTd3zDQEHUjcfFRDq79Jgm4OEyUrEUvp/SwgjfoH2POaE5cvaswIonFwdt8fSzJF5xtAdgcgIoKD8sPpjg==} + + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/cbor@1.0.0': + resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/crypto@1.0.0': + resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.0.0': + resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/webauthn@1.0.0': + resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oxc-project/types@0.112.0': + resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} + + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + + '@pnpm/deps.graph-sequencer@1100.0.1': + resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} + engines: {node: '>=22.13'} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@portabletext/toolkit@3.0.3': + resolution: {integrity: sha512-EI59/l4E0E5wCyWUzvW1FPKWfqDsbebCnCf9ZDHjXGH95X4EORlCtnPuA79PpKaHv0LTChORy55trRzW6tYnOQ==} + engines: {node: ^14.13.1 || >=16.0.0} + + '@portabletext/toolkit@5.0.2': + resolution: {integrity: sha512-Njc1LE1PMJkTx/wEPqZ6sOWGgFgX2B47fxpOQ/Ia4ByhsZoA5Sq8dNvvV5F052j/xE8TbOLiBEjS848FkKADDQ==} + engines: {node: '>=20.19 <22 || >=22.12'} + + '@portabletext/types@2.0.15': + resolution: {integrity: sha512-2e6i2gSQsrA/5OL5Gm4/9bxB9MNO73Fa47zj+0mT93xkoQUCGCWX5fZh1YBJ86hszaRYlqvqG08oULxvvPPp/Q==} + engines: {node: ^14.13.1 || >=16.0.0 || >=18.0.0} + + '@portabletext/types@4.0.2': + resolution: {integrity: sha512-djfIGU9n6DRrunlvj2nIDAp17URo/nA4jSXGvf+Gupx8NLLy9fmJBZ3GL8yhqn9lSVc+cKCharjOa3aOBnWbRw==} + engines: {node: '>=20.19 <22 || >=22.12'} + + '@quansync/fs@1.1.0': + resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + + '@rolldown/binding-android-arm-eabi@1.2.10': + resolution: {integrity: sha512-bp9svZb+QurZeh+8H4BhrZkifEB0YBNvTVzNSJnJQkj4NrRwmQoDUCGP0vSN7PbvLeM7l1tK6GXL8mrTiH2myg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-android-arm64@1.2.10': + resolution: {integrity: sha512-wm6Dld3RXUAZ/gRWKyUy+4W1B5CB5UeFaOzsSWJWEdxZXHH8rCYiZ5dGe6oJmhsunAPWzL7FZV+VtvmN5Ye2eA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-JWWLzvcmc/3pe7qdJqPpuPk91SoE/N+f3PcWx/6ZwuyDVyungAEJPvKm/eEldiDdwTmaEzWfIR+HORxYWrCi1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-arm64@1.2.10': + resolution: {integrity: sha512-UbEfXq/AqGNgRTV3ik+X/iR6mUxu2QdYAadwRxJWquUGnW6gDqdP1FtLtFXRow7RJx0ssRwi80XAPr4r+4DtsA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + resolution: {integrity: sha512-MTakBxfx3tde5WSmbHxuqlDsIW0EzQym+PJYGF4P6lG2NmKzi128OGynoFUqoD5ryCySEY85dug4v+LWGBElIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.10': + resolution: {integrity: sha512-7f5h17q5KZVx/ji1vb8OTq31ch1O2I7K8NPIr44GkyWTApXMIsmhWqZfgpOH10xeauqghDAvGlZktasCkcF6Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + resolution: {integrity: sha512-jje3oopyOLs7IwfvXoS6Lxnmie5JJO7vW29fdGFu5YGY1EDbVDhD+P9vDihqS5X6fFiqL3ZQZCMBg6jyHkSVww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-freebsd-x64@1.2.10': + resolution: {integrity: sha512-ynOk/eEYhC6ZB2xCGvKrEOwE58oBy9LnrAqtkrDF9Fz1VTaNdGZTsV0VarJdhPwb+sOJTGjCLwcuyRJZ1dnMcQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + resolution: {integrity: sha512-A0n8P3hdLAaqzSFrQoA42p23ZKBYQOw+8EH5r15Sa9X1kD9/JXe0YT2gph2QTWvdr0CVK2BOXiK6ENfy6DXOag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.10': + resolution: {integrity: sha512-ERrAs185meZZhGan7a4l3RiiJK1ArSDlHdST++uvSxe+FDbR4TwUPahT/cbZJvaG6fIpDpF78surN+tX708Y4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-kWXkoxxarYISBJ4bLNf5vFkEbb4JvccOwxWDxuK9yee8lg5XA7OpvlTptfRuwEvYcOZf+7VS69Uenpmpyo5Bjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-gnu@1.2.10': + resolution: {integrity: sha512-KN7OHKD0J3jy1UzBwZWPxpwhODf9IARUIJcrH+yLYKOcmegZ8luEUM38lDP1bDVj40yP6PsSzCqOJF76vljFnQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-Z03/wrqau9Bicfgb3Dbs6SYTHliELk2PM2LpG2nFd+cGupTMF5kanLEcj2vuuJLLhptNyS61rtk7SOZ+lPsTUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-arm64-musl@1.2.10': + resolution: {integrity: sha512-8l9wP8O+wa8zD6iw6egSfzVtu7oZVfH3hlUsMM4MwbLMhxleqeoXbZzjddyK3YyNlwLhqznq3tF7PkNJ8T/V2w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-ppc64-gnu@1.2.10': + resolution: {integrity: sha512-SeXNKeQzA5kLhz/J0CH6ZP0/HJ3v1xm/0YbiYpE0kK7emfRC2OIGGIaE14xzkISEGv2aYuUSpiLiU5Gbq+OI0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.10': + resolution: {integrity: sha512-mtht0nR+y8/hart4175Ll15w7lY8dg7CtQ+j2FDNTsDRspOWTK/2V3l0aj9sIj7XmvqxT8Yli/wq22e7feTTWg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + resolution: {integrity: sha512-iSXXZsQp08CSilff/DCTFZHSVEpEwdicV3W8idHyrByrcsRDVh9sGC3sev6d8BygSGj3vt8GvUKBPCoyMA4tgQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.10': + resolution: {integrity: sha512-FSM94nGd55NYo48usCyM/nHfUKRnqc9+b0vJNuKV0oCCpIp/OGims7rO1Nv/DkFkt0S/s2rxsJ2kkS8J3HcpeA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + resolution: {integrity: sha512-qaj+MFudtdCv9xZo9znFvkgoajLdc+vwf0Kz5N44g+LU5XMe+IsACgn3UG7uTRlCCvhMAGXm1XlpEA5bZBrOcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-x64-musl@1.2.10': + resolution: {integrity: sha512-C3YxNB16myRLs7o+B+6PnQ6jBsdIS4+AE4Ah8glVGhDpEv9AOvxhZ/1duAb4B0UGczEK/lBbccksd8VI+p6zfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + resolution: {integrity: sha512-U662UnMETyjT65gFmG9ma+XziENrs7BBnENi/27swZPYagubfHRirXHG2oMl+pEax2WvO7Kb9gHZmMakpYqBHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-openharmony-arm64@1.2.10': + resolution: {integrity: sha512-571TlE/F1eeTjjdjYAMMMPs1Mfv3MtX6s3+ZKVU6HiUjZ5Njc6c/qzNy/8K3zALTZnaw3JQVYrHxvNfjm43KAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3': + resolution: {integrity: sha512-gekrQ3Q2HiC1T5njGyuUJoGpK/l6B/TNXKed3fZXNf9YRTJn3L5MOZsFBn4bN2+UX+8+7hgdlTcEsexX988G4g==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-85y5JifyMgs8m5K2XzR/VDsapKbiFiohl7s5lEj7nmNGO0pkTXE7q6TQScei96BNAsoK7JC3pA7ukA8WRHVJpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-arm64-msvc@1.2.10': + resolution: {integrity: sha512-QXW+ZWaiqs2c7Fi++D/SsW07LTPcUrncxcskJGfGNBoaLik1IU6fJymz4HsqwEO0u5Iq11yTO0B/mc4cPk7jrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + resolution: {integrity: sha512-a4VUQZH7LxGbUJ3qJ/TzQG8HxdHvf+jOnqf7B7oFx1TEBm+j2KNL2zr5SQ7wHkNAcaPevF6gf9tQnVBnC4mD+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.10': + resolution: {integrity: sha512-5FQFGgah17YeMtG1Yd5a+rMxQpTksyNXxRtKz06FVTaQw3RKYUJQbUoKk0/5jrXBpDo+7makNP7UHA2LQyH64A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.4.3': + resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.4.3': + resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.4.3': + resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==} + engines: {node: '>=20'} + + '@shikijs/langs@4.4.3': + resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.4.3': + resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==} + engines: {node: '>=20'} + + '@shikijs/themes@4.4.3': + resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==} + engines: {node: '>=20'} + + '@shikijs/types@4.4.3': + resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + + '@tanstack/history@1.161.4': + resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.163.2': + resolution: {integrity: sha512-1LosUlpL2mRMWxUZXmkEg5+Br5P5j9TrLngqRgHVbZoFkjnbcj1x9fQN2OVLrBv9Npw97NRsHeJljnAH/c7oSw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.163.2': + resolution: {integrity: sha512-mD0Pav6kcpS317XSJN+wCZaxLLngDhlwgzPNca56dWCp8YKPEvhhj/Zdl+LdRlJQ2VJ5BOy7FbOV1hErc9Nj5Q==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + + '@tiptap/core@3.31.3': + resolution: {integrity: sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==} + peerDependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3': + resolution: {integrity: sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3': + resolution: {integrity: sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-bubble-menu@3.31.3': + resolution: {integrity: sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bullet-list@3.31.3': + resolution: {integrity: sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-character-count@3.31.3': + resolution: {integrity: sha512-PVYEnEUqzjwSMfhHW+TzgoNB4UqYQQLKsY97CBsfuRswEfsqeViN3kMEuJEMMzN2fFnkB0hkRTpGlnMYcbd6Ag==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-code-block-lowlight@3.20.0': + resolution: {integrity: sha512-9lN9rn07lOWkLnByT5C1axtq56MHpOI7MpLaCmX3p+x1bDl6Uvixm6AoBdTLfZUmUYeEFBsf7t5cR+QepMbkiA==} + peerDependencies: + '@tiptap/core': ^3.20.0 + '@tiptap/extension-code-block': ^3.20.0 + '@tiptap/pm': ^3.20.0 + highlight.js: ^11 + lowlight: ^2 || ^3 + + '@tiptap/extension-code-block@3.31.3': + resolution: {integrity: sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3': + resolution: {integrity: sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-collaboration@3.31.3': + resolution: {integrity: sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': ^3.0.7 + yjs: ^13 + + '@tiptap/extension-document@3.31.3': + resolution: {integrity: sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-drag-handle-react@3.31.3': + resolution: {integrity: sha512-Ri0sRSvKmOkXDgLlw6bSOC5aAv+TCyjZkRcrP0YnJ9AFq3CFOyNhpiWFim7GASU9O+o6CcDpekm+01aTTuO7rw==} + peerDependencies: + '@tiptap/extension-drag-handle': 3.31.3 + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3 + react: ^16.8 || ^17 || ^18 || ^19 + react-dom: ^16.8 || ^17 || ^18 || ^19 + + '@tiptap/extension-drag-handle@3.31.3': + resolution: {integrity: sha512-2WOcg5wbGTJNlzLzdaw0IQgbzObjs2k55ZYZito3WRsMKZ3BPJK8pj5GOYKnJ7dm6VlKrpP7WmNq3HGpxe5GOA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/extension-collaboration': 3.31.3 + '@tiptap/extension-node-range': 3.31.3 + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': ^3.0.7 + + '@tiptap/extension-dropcursor@3.31.3': + resolution: {integrity: sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-floating-menu@3.31.3': + resolution: {integrity: sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-focus@3.31.3': + resolution: {integrity: sha512-2sA9o+4F1pSwtZ2BMhoU31zsrTytUKI6SNXVrcABrljW4EwX3LWSNAyAUu2uNc98BL3acQEBLdf7dDs6nS/pRw==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-gapcursor@3.31.3': + resolution: {integrity: sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-hard-break@3.31.3': + resolution: {integrity: sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-heading@3.31.3': + resolution: {integrity: sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-horizontal-rule@3.31.3': + resolution: {integrity: sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-image@3.31.3': + resolution: {integrity: sha512-wWNG9BOtx2Cg4vwxPVGDIJ5DGX+ETkldeoaFJLB1NXUL4OPUiVTf8cHZ+hdYgJWP704BEB7jQgjlpvdi6VigTg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-italic@3.31.3': + resolution: {integrity: sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-link@3.31.3': + resolution: {integrity: sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-list-item@3.31.3': + resolution: {integrity: sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list-keymap@3.31.3': + resolution: {integrity: sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-list@3.31.3': + resolution: {integrity: sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-node-range@3.31.3': + resolution: {integrity: sha512-8/CKctvTNmzkPkqC9wxVY4TVhOax5WkqiImkvMb3Qv+OJgKC0pTlAhSS+ks3YYTbbmJuCYvHef3QuGiwRnu9gw==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-ordered-list@3.31.3': + resolution: {integrity: sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==} + peerDependencies: + '@tiptap/extension-list': 3.31.3 + + '@tiptap/extension-paragraph@3.31.3': + resolution: {integrity: sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-placeholder@3.31.3': + resolution: {integrity: sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==} + peerDependencies: + '@tiptap/extensions': 3.31.3 + + '@tiptap/extension-strike@3.31.3': + resolution: {integrity: sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-subscript@3.31.3': + resolution: {integrity: sha512-jUMMtg4QF7wIXIvsgBmgJjdQMETOX4KMF+Nl/0nuCKs59lEi1StOJzkiEdm+UbFhxvvY9B30uLVgzogGIlHHyg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-superscript@3.31.3': + resolution: {integrity: sha512-WzM84fb8akg1Ni2b1DcE1pE2kkWLbkEVxN2DKElzCVk/kuSNp+VKqOjunHGTnO+kJhXdcmXTVqAO4Vp2kiDxMQ==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-table-cell@3.31.3': + resolution: {integrity: sha512-5nueKR/p/IX6B4etWqHjyRsjNfDf6dJZaRgfw1/1l90CJe2h3tTZk4JbUKWN3Mo4FzZuYKzmU3fxcZ8pSVOeJQ==} + peerDependencies: + '@tiptap/extension-table': 3.31.3 + + '@tiptap/extension-table-header@3.31.3': + resolution: {integrity: sha512-sstVtNQiBYX4P16vlhwBYPsthxDnodHWQfGq0EUQM40Z7H796Lgh9t1qYtu8yARQuHKzvINRwJCSy6DZ8YTDtQ==} + peerDependencies: + '@tiptap/extension-table': 3.31.3 + + '@tiptap/extension-table-row@3.31.3': + resolution: {integrity: sha512-up6tDK+hYVTFDeJ3XqKO0WJqBzyXNfGxMoimo5h83jrq9dcNh0oPCOzglZ7rdo8GH9I3VJ2fNKxxU2Eg2HcMoQ==} + peerDependencies: + '@tiptap/extension-table': 3.31.3 + + '@tiptap/extension-table@3.31.3': + resolution: {integrity: sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-text-align@3.31.3': + resolution: {integrity: sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-text@3.31.3': + resolution: {integrity: sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-typography@3.31.3': + resolution: {integrity: sha512-Ec2bsAR63dmiLD/5ph8ky3ptHWrBethbPG3a6p2FuB1VJ8LtlocCsDaXR5TvjZkUx2YQIaS2A+yzT5v7cumLgg==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extension-underline@3.31.3': + resolution: {integrity: sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==} + peerDependencies: + '@tiptap/core': 3.31.3 + + '@tiptap/extensions@3.31.3': + resolution: {integrity: sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + resolution: {integrity: sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==} + + '@tiptap/react@3.31.3': + resolution: {integrity: sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==} + peerDependencies: + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.31.3': + resolution: {integrity: sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==} + + '@tiptap/suggestion@3.31.3': + resolution: {integrity: sha512-z1OQ/Yx6seMZehi1AcmNkyJ8qkHQzybArmCyRdmreS4YL9C4bPMBe5lbMfFsArYs+KD5JyHwps5j7J/YE5BIuw==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.31.3 + '@tiptap/pm': 3.31.3 + + '@tiptap/y-tiptap@3.0.9': + resolution: {integrity: sha512-7/El8NQ8R5V5MkdrOUdfj9IgZacpt0H071xNimX7B0AnYiWiKefQnMKd41neQYzo2MOXbWdN3iZ+7Z7BruzOSA==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + prosemirror-model: ^1.7.1 + prosemirror-state: ^1.2.3 + prosemirror-view: ^1.9.10 + y-protocols: ^1.0.1 + yjs: ^13.5.38 + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@unpic/placeholder@0.1.2': + resolution: {integrity: sha512-O++tS97biojo5sqn5TeTt+jUjl5gWOdIQuOXe8YluTJWq4L0GM6VuTkaspNpsmxHfioJw/6YBirzOpG4t87l8Q==} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@wordpress/block-serialization-default-parser@5.56.0': + resolution: {integrity: sha512-ZS2DpbXSFke+Hqld0l2oB8SxNj49AC6MidE0CKA0MYk5Xd/eBeDUNt/JqjpVJlWcdiO+7kVCe8VP7YPOkON64A==} + engines: {node: '>=18.12.0'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + am-i-vibing@0.4.0: + resolution: {integrity: sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==} + hasBin: true + + ansis@4.4.0: + resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-kit@3.0.0: + resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + astro-portabletext@0.11.4: + resolution: {integrity: sha512-2dOPJAZs6IiVotFaQB5Em0fYUNhX7cf2FfWf0dXfQ67wLmLXsUiQnRzb1BORrc9LS8YVQhXjhNXARB4kZwO+2w==} + peerDependencies: + astro: '>=4.6.0' + + astro@7.3.5: + resolution: {integrity: sha512-1p/ernaq/RuESbM5NMjWzbO6SiebMGrGcYlGjMfBhzzgM+n/X7AUmVyw8BqW2QYCdivpsHwJBcD1fqmlTZjZHg==} + engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + birpc@4.2.0: + resolution: {integrity: sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + blurhash@2.0.5: + resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + citty@0.2.2: + resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + + cjs-module-lexer@1.2.3: + resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie-es@2.0.1: + resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.0.2: + resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + dompurify@3.4.16: + resolution: {integrity: sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + dts-resolver@2.1.3: + resolution: {integrity: sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw==} + engines: {node: '>=20.19.0'} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emdash@0.39.0: + resolution: {integrity: sha512-A/++t7R7rBcs47AVzIYbx7Np3S9ssYha+xrpp9We2SzYlu+yHvcnTvKOkWXjLCy6MqqK7z66eBQCAm3aTzQo6Q==} + engines: {node: '>=22.16'} + hasBin: true + peerDependencies: + '@astrojs/react': '>=5.0.0-beta.0' + '@emdash-cms/auth-atproto': '>=0.2.8' + astro: '>=6.0.0-beta.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + peerDependenciesMeta: + '@emdash-cms/auth-atproto': + optional: true + + emdash@0.39.1: + resolution: {integrity: sha512-rgiIms+5R+cB0pxcaAamjVyc3XOQId7qMTb+ziSBCOdF3NIT1aqGCuDKDehL5bmFQPw27zKINtJshpZw6T/KaQ==} + engines: {node: '>=22.16'} + hasBin: true + peerDependencies: + '@astrojs/react': '>=5.0.0-beta.0' + '@emdash-cms/auth-atproto': '>=0.2.8' + astro: '>=6.0.0-beta.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + peerDependenciesMeta: + '@emdash-cms/auth-atproto': + optional: true + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.7.0: + resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-equals@5.4.3: + resolution: {integrity: sha512-8unmvA0qrfpFWBYUate9BTGtQdKHWBYt+KdAIkag7Vvxe7wz8Q2nJg7fLMC0bJqe0bjP31kca7Ckaawrs4Istg==} + engines: {node: '>=6.0.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-proc@0.2.0: + resolution: {integrity: sha512-a6h2nTrgB3g5Wrn4au9Ux4kMk7IJtyF6oA8uGn+mk3weLZssUtc1cp6meHIzzuW2o1srrBAxzkmXLbqjRkgB6w==} + engines: {node: ^20.19.0 || >=22.12.0} + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + get-tsconfig@5.0.0-beta.4: + resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + highlight.js@11.11.2: + resolution: {integrity: sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==} + engines: {node: '>=12.0.0'} + + hono@4.13.9: + resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} + engines: {node: '>=16.9.0'} + + hookable@6.1.2: + resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-id@4.2.1: + resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + import-without-cache@0.2.5: + resolution: {integrity: sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A==} + engines: {node: '>=20.19.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.2: + resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.1.0: + resolution: {integrity: sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + + js-base64@3.9.4: + resolution: {integrity: sha512-PtOMXpEGuP0RRiRXsjzHzl44dMHxSu2CPvAhinupR1tBa88me+1DPqsobl7eupn5UukjLkln1ZnAOAOXOrYG0Q==} + + js-sha256@0.10.1: + resolution: {integrity: sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + kysely-d1@0.4.0: + resolution: {integrity: sha512-wUcVvQNtm30OTfuo7Ad5vYJ1qHqPXOCZc+zWchVKNyuvqY3u8OuGw4gmUx1Ypdx2wRVFLHVQC9I7v0pTmF7Nkw==} + peerDependencies: + kysely: '*' + + kysely@0.29.6: + resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==} + engines: {node: '>=22.0.0'} + + launch-editor@2.14.1: + resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==} + + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + + layerr@3.0.0: + resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==} + + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + + libsql@0.3.19: + resolution: {integrity: sha512-Aj5cQ5uk/6fHdmeW0TiXK42FqUlwx7ytmMLPSaUQPin5HKKKuUPD62MAbN4OEweGBBI7q1BekoEN4gPUEL6MZA==} + cpu: [x64, arm64, wasm32] + os: [darwin, linux, win32] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@4.1.0: + resolution: {integrity: sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==} + engines: {node: '>=16'} + hasBin: true + + miniflare@5.20260921.1-alpha: + resolution: {integrity: sha512-nvw7bDhUCMTKbpZWJ/WHQFn1FDopseuMQQGxkO3/f0r9eXeizux0ZxV3sZnEHnS0lDWb3Je0bIO2MJcFK4O8GQ==} + engines: {node: '>=22.0.0'} + + modern-tar@0.7.7: + resolution: {integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==} + engines: {node: '>=18.0.0'} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.43.0: + resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.12: + resolution: {integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-queue@9.3.3: + resolution: {integrity: sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + prosemirror-changeset@2.4.3: + resolution: {integrity: sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==} + + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + prosemirror-dropcursor@1.8.4: + resolution: {integrity: sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.12: + resolution: {integrity: sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.12.1: + resolution: {integrity: sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==} + + prosemirror-view@1.42.5: + resolution: {integrity: sha512-7qcRaCWM48NC4utNQnx5Obvvs48/jMHXXvOXHH6WlBojmiK15eyxoHYlv5aJARHJHiFccSuWZ7ULW2SPrFAS/g==} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-hotkeys-hook@5.3.3: + resolution: {integrity: sha512-aswgyWUnE25hmhzHTfKDmKzsaSE5DJ4LKaU/o6rQSXkDd/1Bh9TfAFQbHkf6fLy11HvlYkp+cDDarGdhmCDhoQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + react-image-crop@11.1.2: + resolution: {integrity: sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==} + peerDependencies: + react: '>=16.13.1' + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + rolldown-plugin-dts@0.22.5: + resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@ts-macro/tsc': ^0.3.6 + '@typescript/native-preview': '>=7.0.0-dev.20250601.1' + rolldown: ^1.0.0-rc.3 + typescript: ^5.0.0 || ^6.0.0-beta + vue-tsc: ~3.2.0 + peerDependenciesMeta: + '@ts-macro/tsc': + optional: true + '@typescript/native-preview': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.0.0-rc.17: + resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.0.0-rc.3: + resolution: {integrity: sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rolldown@1.2.10: + resolution: {integrity: sha512-OxkA08pSryMK7B3XiFA09B4OJ1xJMPgIYCBMY2xchzpqgBGsV1o0DetPAE+Sl3N3L4oCPiEzmHVSOj7iR04Zog==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-html@2.17.7: + resolution: {integrity: sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==} + engines: {node: '>=22.12.0'} + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seroval-plugins@1.6.7: + resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.7: + resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} + engines: {node: '>=10'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} + engines: {node: '>= 0.4'} + + shiki@4.4.3: + resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==} + engines: {node: '>=20'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tsdown@0.20.3: + resolution: {integrity: sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@vitejs/devtools': '*' + publint: ^0.3.0 + typescript: ^5.0.0 + unplugin-lightningcss: ^0.4.0 + unplugin-unused: ^0.5.0 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + typescript: + optional: true + unplugin-lightningcss: + optional: true + unplugin-unused: + optional: true + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ulidx@2.4.1: + resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} + engines: {node: '>=16'} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.11.2: + resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} + engines: {node: '>=22.19.0'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unicode-segmenter@0.17.3: + resolution: {integrity: sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.5: + resolution: {integrity: sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unrun@0.2.39: + resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} + engines: {node: '>=20.19.0'} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + upng-js@2.1.0: + resolution: {integrity: sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==} + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + valibot@1.5.0: + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verkit@0.4.1: + resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} + engines: {node: '>=18.12.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + 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 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260921.1: + resolution: {integrity: sha512-4HyG7G1W4ksa6tUZ8bV2jxDRWuL5PXnHm9+Z1sjFPb9OZNoYtXz4y7QQRh4ibi0BF/lOmlAVjbhkUqsAVZuUKA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.138.0: + resolution: {integrity: sha512-jxNlOfssgyMo+eUcWBX+BxAuU6/AZAn8mNimicLv4igxbRH40OFRBMtGJT8z4kJwXPJriy1O4TB7A8lYjKb82w==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260921.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yjs@13.6.33: + resolution: {integrity: sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/cloudflare@14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1)': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/underscore-redirects': 1.0.4 + '@cloudflare/vite-plugin': 1.59.0(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)) + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + piccolore: 0.1.3 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) + transitivePeerDependencies: + - '@types/node' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - utf-8-validate + - yaml + + '@astrojs/compiler-binding-android-arm64@0.5.0': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.0': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.0': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.0': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.0': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.0': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.0': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.0': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.0': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.0': + optional: true + + '@astrojs/compiler-binding@0.5.0': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.0 + '@astrojs/compiler-binding-darwin-arm64': 0.5.0 + '@astrojs/compiler-binding-darwin-x64': 0.5.0 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.0 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.0 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.0 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.0 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.0 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.0 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.0 + + '@astrojs/compiler-rs@0.5.0': + dependencies: + '@astrojs/compiler-binding': 0.5.0 + + '@astrojs/internal-helpers@0.11.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + js-yaml: 4.3.2 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.4.3 + smol-toml: 1.9.0 + unified: 11.0.5 + + '@astrojs/markdown-satteri@0.4.2': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/prism': 4.0.2 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/prism@4.0.2': + dependencies: + prismjs: 1.30.0 + + '@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1)': + dependencies: + '@astrojs/internal-helpers': 0.11.0 + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': 6.1.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + devalue: 5.9.4 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + ultrahtml: 1.7.0 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + transitivePeerDependencies: + - '@rolldown/plugin-babel' + - '@types/node' + - '@vitejs/devtools' + - babel-plugin-react-compiler + - esbuild + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + '@astrojs/telemetry@3.3.3': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.8.0 + + '@astrojs/underscore-redirects@1.0.4': {} + + '@atcute/atproto@4.0.4(@atcute/lexicons@2.1.1)': + dependencies: + '@atcute/lexicons': 2.1.1 + + '@atcute/car@6.1.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)': + dependencies: + '@atcute/cbor': 2.3.8(@atcute/cid@2.5.0) + '@atcute/cid': 2.5.0 + '@atcute/uint8array': 1.2.0 + '@atcute/varint': 2.0.2 + + '@atcute/cbor@2.3.8(@atcute/cid@2.5.0)': + dependencies: + '@atcute/cid': 2.5.0 + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + + '@atcute/cid@2.5.0': + dependencies: + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + + '@atcute/client@5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3)': + dependencies: + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + transitivePeerDependencies: + - typescript + + '@atcute/client@5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + transitivePeerDependencies: + - typescript + + '@atcute/crypto@2.4.4': + dependencies: + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + '@noble/secp256k1': 3.2.0 + + '@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3)': + dependencies: + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/util-fetch': 2.0.2(typescript@5.9.3) + valibot: 1.5.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/util-fetch': 2.0.2(typescript@6.0.3) + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3)': + dependencies: + '@atcute/lexicons': 2.1.1 + valibot: 1.5.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3)': + dependencies: + '@atcute/lexicons': 2.1.1 + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/lexicons@2.1.1': + dependencies: + '@atcute/uint8array': 1.2.0 + '@atcute/util-text': 1.3.4 + '@oomfware/eval': 0.1.0 + '@standard-schema/spec': 1.1.0 + esm-env: 1.2.2 + + '@atcute/mst@1.1.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)': + dependencies: + '@atcute/cbor': 2.3.8(@atcute/cid@2.5.0) + '@atcute/cid': 2.5.0 + '@atcute/uint8array': 1.2.0 + + '@atcute/multibase@1.2.5': + dependencies: + '@atcute/uint8array': 1.2.0 + + '@atcute/oauth-crypto@1.0.1(typescript@6.0.3)': + dependencies: + '@atcute/multibase': 1.2.5 + '@atcute/uint8array': 1.2.0 + nanoid: 5.1.16 + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/oauth-keyset@0.1.3(typescript@6.0.3)': + dependencies: + '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/oauth-node-client@2.0.1(@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3)': + dependencies: + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/oauth-crypto': 1.0.1(typescript@6.0.3) + '@atcute/oauth-keyset': 0.1.3(typescript@6.0.3) + '@atcute/oauth-types': 1.0.1(typescript@6.0.3) + '@atcute/util-fetch': 2.0.2(typescript@6.0.3) + nanoid: 5.1.16 + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/oauth-types@1.0.1(typescript@6.0.3)': + dependencies: + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/oauth-keyset': 0.1.3(typescript@6.0.3) + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/repo@1.1.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/lexicons@2.1.1)': + dependencies: + '@atcute/car': 6.1.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0) + '@atcute/cbor': 2.3.8(@atcute/cid@2.5.0) + '@atcute/cid': 2.5.0 + '@atcute/crypto': 2.4.4 + '@atcute/lexicons': 2.1.1 + '@atcute/mst': 1.1.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0) + '@atcute/uint8array': 1.2.0 + + '@atcute/uint8array@1.2.0': {} + + '@atcute/util-fetch@2.0.2(typescript@5.9.3)': + dependencies: + valibot: 1.5.0(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@atcute/util-fetch@2.0.2(typescript@6.0.3)': + dependencies: + valibot: 1.5.0(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + '@atcute/util-text@1.3.4': + dependencies: + unicode-segmenter: 0.17.3 + + '@atcute/varint@2.0.2': {} + + '@babel/generator@8.0.0-rc.2': + dependencies: + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.6': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.0-rc.2': {} + + '@babel/helper-validator-identifier@8.0.6': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.0-rc.2': + dependencies: + '@babel/types': 8.0.0-rc.2 + + '@babel/parser@8.0.6': + dependencies: + '@babel/types': 8.0.6 + + '@babel/runtime@7.29.7': {} + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.0-rc.2': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + + '@base-ui/react@1.8.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@date-fns/tz': 1.5.0 + '@types/react': 19.3.0 + date-fns: 4.4.0 + + '@base-ui/utils@0.4.0(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + reselect: 5.3.0 + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@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.5 + + '@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.5 + + '@changesets/changelog-git@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/cli@3.0.3': + dependencies: + '@changesets/apply-release-plan': 8.1.1 + '@changesets/assemble-release-plan': 7.0.0 + '@changesets/changelog-git': 1.0.0 + '@changesets/config': 4.0.1 + '@changesets/errors': 1.0.0 + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/git': 4.0.1 + '@changesets/pre': 3.0.0 + '@changesets/read': 1.0.1 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@changesets/write': 1.0.1 + '@clack/prompts': 1.8.1 + '@manypkg/get-packages': 3.1.0 + '@pnpm/deps.graph-sequencer': 1100.0.1 + cac: 7.0.0 + import-meta-resolve: 4.2.0 + launch-editor: 2.14.1 + package-manager-detector: 1.8.0 + semver: 7.8.5 + tinyexec: 1.3.1 + + '@changesets/config@4.0.1': + dependencies: + '@changesets/get-dependents-graph': 3.0.0 + '@changesets/should-skip-package': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + + '@changesets/errors@1.0.0': {} + + '@changesets/format@0.1.2': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.3.1 + + '@changesets/get-dependents-graph@3.0.0': + dependencies: + '@changesets/types': 7.0.0 + semver: 7.8.5 + + '@changesets/git@4.0.1': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + picomatch: 4.0.7 + tinyexec: 1.3.1 + + '@changesets/parse@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + yaml: 2.9.1 + + '@changesets/pre@3.0.0': + dependencies: + '@changesets/errors': 1.0.0 + '@changesets/types': 7.0.0 + '@manypkg/get-packages': 3.1.0 + + '@changesets/read@1.0.1': + dependencies: + '@changesets/git': 4.0.1 + '@changesets/parse': 1.0.0 + '@changesets/types': 7.0.0 + + '@changesets/should-skip-package@1.0.0': + dependencies: + '@changesets/types': 7.0.0 + + '@changesets/types@7.0.0': {} + + '@changesets/write@1.0.1': + dependencies: + '@changesets/format': 0.1.2 + '@changesets/types': 7.0.0 + human-id: 4.2.1 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@cloudflare/ai-search-snippet@0.0.42': {} + + '@cloudflare/kumo@2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': + dependencies: + '@base-ui/react': 1.8.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + clsx: 2.1.1 + motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + shiki: 4.4.3 + tailwind-merge: 3.7.0 + optionalDependencies: + echarts: 6.1.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260921.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260921.1 + + '@cloudflare/vite-plugin@1.59.0(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))': + dependencies: + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260921.1) + miniflare: 5.20260921.1-alpha(@types/node@26.6.2) + unenv: 2.0.0-rc.24 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + workerd: 1.20260921.1 + wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) + ws: 8.21.0 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + '@cloudflare/vitest-plugin@1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)))': + dependencies: + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + cjs-module-lexer: 1.2.3 + esbuild: 0.28.1 + miniflare: 5.20260921.1-alpha(@types/node@26.6.2) + vitest: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) + zod: 4.4.3 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@types/node' + - bufferutil + - utf-8-validate + + '@cloudflare/workerd-darwin-64@1.20260921.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260921.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260921.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260921.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260921.1': + optional: true + + '@cloudflare/workers-types@5.20260924.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@date-fns/tz@1.5.0': {} + + '@dnd-kit/accessibility@3.1.1(react@19.3.0)': + dependencies: + react: 19.3.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.3.0) + '@dnd-kit/utilities': 3.2.2(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@dnd-kit/utilities': 3.2.2(react@19.3.0) + react: 19.3.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.3.0)': + dependencies: + react: 19.3.0 + tslib: 2.8.1 + + '@emdash-cms/admin@0.39.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4)': + dependencies: + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@dnd-kit/core': 6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + '@dnd-kit/utilities': 3.2.2(react@19.3.0) + '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@lingui/core': 5.9.5 + '@lingui/react': 5.9.5(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-query': 5.90.21(react@19.3.0) + '@tanstack/react-router': 1.163.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-character-count': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-collaboration': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-drag-handle': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) + '@tiptap/extension-drag-handle-react': 3.31.3(@tiptap/extension-drag-handle@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.3)(@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-node-range': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-subscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-superscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table-cell': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-header': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-row': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dompurify: 3.4.16 + highlight.js: 11.11.2 + lowlight: 3.3.0 + marked: 17.0.6 + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + react-hotkeys-hook: 5.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-image-crop: 11.1.2(react@19.3.0) + tailwind-merge: 3.7.0 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - date-fns + - echarts + - prosemirror-model + - prosemirror-state + - prosemirror-view + - typescript + - zod + + '@emdash-cms/admin@0.39.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4)': + dependencies: + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@dnd-kit/core': 6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + '@dnd-kit/utilities': 3.2.2(react@19.3.0) + '@emdash-cms/blocks': 0.39.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@lingui/core': 5.9.5 + '@lingui/react': 5.9.5(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-query': 5.90.21(react@19.3.0) + '@tanstack/react-router': 1.163.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-character-count': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-collaboration': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-drag-handle': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) + '@tiptap/extension-drag-handle-react': 3.31.3(@tiptap/extension-drag-handle@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.3)(@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-node-range': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-subscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-superscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table-cell': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-header': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-row': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dompurify: 3.4.16 + highlight.js: 11.11.2 + lowlight: 3.3.0 + marked: 17.0.6 + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + react-hotkeys-hook: 5.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-image-crop: 11.1.2(react@19.3.0) + tailwind-merge: 3.7.0 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - date-fns + - echarts + - prosemirror-model + - prosemirror-state + - prosemirror-view + - typescript + - zod + + '@emdash-cms/auth@0.39.0(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/webauthn': 1.0.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + ulidx: 2.4.1 + zod: 4.5.4 + optionalDependencies: + kysely: 0.29.6 + + '@emdash-cms/auth@0.39.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/webauthn': 1.0.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + ulidx: 2.4.1 + zod: 4.5.4 + optionalDependencies: + kysely: 0.29.6 + + '@emdash-cms/blocks@0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': + dependencies: + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + echarts: 6.1.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tailwind-merge: 3.7.0 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + - zod + + '@emdash-cms/blocks@0.39.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': + dependencies: + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + echarts: 6.1.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tailwind-merge: 3.7.0 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + - zod + + '@emdash-cms/cloudflare@0.39.0(a7ee94a070d1e2ec5745e252646edfd0)': + dependencies: + '@astrojs/cloudflare': 14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1) + '@cloudflare/ai-search-snippet': 0.0.42 + '@cloudflare/workers-types': 5.20260924.1 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + emdash: 0.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) + jose: 6.2.12 + kysely: 0.29.6 + kysely-d1: 0.4.0(kysely@0.29.6) + ulidx: 2.4.1 + wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) + optionalDependencies: + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pg: 8.23.0 + react: 19.3.0 + transitivePeerDependencies: + - '@astrojs/react' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emdash-cms/auth-atproto' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - react-dom + - supports-color + - typescript + - utf-8-validate + + '@emdash-cms/gutenberg-to-portable-text@0.39.0': + dependencies: + '@wordpress/block-serialization-default-parser': 5.56.0 + parse5: 7.3.0 + + '@emdash-cms/gutenberg-to-portable-text@0.39.1': + dependencies: + '@wordpress/block-serialization-default-parser': 5.56.0 + parse5: 7.3.0 + + '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@atcute/oauth-node-client': 2.0.1(@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@clack/prompts': 1.8.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@6.0.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@oslojs/crypto': 1.0.1 + chokidar: 5.0.0 + citty: 0.2.2 + consola: 3.4.2 + jsonc-parser: 3.3.1 + modern-tar: 0.7.7 + picocolors: 1.1.1 + tsdown: 0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.3) + typescript: 6.0.3 + zod: 4.5.4 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - '@vitejs/devtools' + - oxc-resolver + - publint + - synckit + - unplugin-lightningcss + - unplugin-unused + - vue-tsc + + '@emdash-cms/plugin-test@0.2.0(59949aeb10c26a9714a29cfa8eddca48)': + dependencies: + '@cloudflare/vitest-plugin': 1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))) + '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/cloudflare': 0.39.0(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@emdash-cms/plugin-types': 0.4.0 + emdash: 0.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) + kysely: 0.29.6 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + vitest: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@astrojs/cloudflare' + - '@astrojs/react' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@cloudflare/kumo' + - '@cloudflare/workers-types' + - '@date-fns/tz' + - '@emdash-cms/auth-atproto' + - '@emnapi/core' + - '@emnapi/runtime' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@phosphor-icons/react' + - '@tiptap/extensions' + - '@ts-macro/tsc' + - '@types/node' + - '@types/react' + - '@types/react-dom' + - '@typescript/native-preview' + - '@vitejs/devtools' + - '@vitest/runner' + - '@vitest/snapshot' + - astro + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - oxc-resolver + - pg + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - publint + - react + - react-dom + - supports-color + - synckit + - typescript + - unplugin-lightningcss + - unplugin-unused + - utf-8-validate + - vue-tsc + - wrangler + - zod + + '@emdash-cms/plugin-types@0.4.0': + dependencies: + zod: 4.5.4 + + '@emdash-cms/registry-client@0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3)': + dependencies: + '@atcute/atproto': 4.0.4(@atcute/lexicons@2.1.1) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/crypto': 2.4.4 + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@atcute/repo': 1.1.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/lexicons@2.1.1) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-moderation': 0.2.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - typescript + + '@emdash-cms/registry-client@0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@6.0.3)': + dependencies: + '@atcute/atproto': 4.0.4(@atcute/lexicons@2.1.1) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/crypto': 2.4.4 + '@atcute/identity': 2.0.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@atcute/repo': 1.1.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/lexicons@2.1.1) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-moderation': 0.2.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - typescript + + '@emdash-cms/registry-lexicons@0.6.0': + dependencies: + '@atcute/atproto': 4.0.4(@atcute/lexicons@2.1.1) + '@atcute/lexicons': 2.1.1 + + '@emdash-cms/registry-moderation@0.2.0': + dependencies: + '@atcute/cbor': 2.3.8(@atcute/cid@2.5.0) + '@atcute/cid': 2.5.0 + '@atcute/crypto': 2.4.4 + '@atcute/multibase': 1.2.5 + + '@emdash-cms/registry-verification@0.3.2': + dependencies: + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-lexicons': 0.6.0 + modern-tar: 0.7.7 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@floating-ui/react@0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@floating-ui/utils': 0.2.12 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.12': {} + + '@hono/node-server@2.1.1(hono@4.13.9)': + dependencies: + hono: 4.13.9 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@libsql/client@0.8.1': + dependencies: + '@libsql/core': 0.8.1 + '@libsql/hrana-client': 0.6.2 + js-base64: 3.9.4 + libsql: 0.3.19 + promise-limit: 2.7.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + '@libsql/core@0.8.1': + dependencies: + js-base64: 3.9.4 + optional: true + + '@libsql/darwin-arm64@0.3.19': + optional: true + + '@libsql/darwin-x64@0.3.19': + optional: true + + '@libsql/hrana-client@0.6.2': + dependencies: + '@libsql/isomorphic-fetch': 0.2.5 + '@libsql/isomorphic-ws': 0.1.5 + js-base64: 3.9.4 + node-fetch: 3.3.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + '@libsql/isomorphic-fetch@0.2.5': + optional: true + + '@libsql/isomorphic-ws@0.1.5': + dependencies: + '@types/ws': 8.18.1 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + '@libsql/kysely-libsql@0.4.1(kysely@0.29.6)': + dependencies: + '@libsql/client': 0.8.1 + kysely: 0.29.6 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + + '@libsql/linux-arm64-gnu@0.3.19': + optional: true + + '@libsql/linux-arm64-musl@0.3.19': + optional: true + + '@libsql/linux-x64-gnu@0.3.19': + optional: true + + '@libsql/linux-x64-musl@0.3.19': + optional: true + + '@libsql/win32-x64-msvc@0.3.19': + optional: true + + '@lingui/core@5.9.5': + dependencies: + '@babel/runtime': 7.29.7 + '@lingui/message-utils': 5.9.5 + + '@lingui/message-utils@5.9.5': + dependencies: + '@messageformat/parser': 5.1.1 + js-sha256: 0.10.1 + + '@lingui/react@5.9.5(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@lingui/core': 5.9.5 + react: 19.3.0 + + '@manypkg/find-root@3.1.0': + dependencies: + '@manypkg/tools': 2.1.2 + + '@manypkg/get-packages@3.1.0': + dependencies: + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + + '@manypkg/tools@2.1.2': + dependencies: + jju: 1.4.0 + tinyglobby: 0.2.17 + yaml: 2.9.1 + + '@messageformat/parser@5.1.1': + dependencies: + moo: 0.5.3 + + '@modelcontextprotocol/sdk@1.30.1(supports-color@10.2.2)(zod@4.5.4)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.9) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1(supports-color@10.2.2) + express-rate-limit: 8.7.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2) + hono: 4.13.9 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.5.4 + zod-to-json-schema: 3.25.2(zod@4.5.4) + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@neon-rs/load@0.0.4': + optional: true + + '@noble/secp256k1@3.2.0': {} + + '@oomfware/eval@0.1.0': {} + + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/cbor@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/crypto@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.0.0': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + + '@oslojs/webauthn@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + '@oslojs/cbor': 1.0.0 + '@oslojs/crypto': 1.0.0 + '@oslojs/encoding': 1.0.0 + + '@oxc-project/types@0.112.0': {} + + '@oxc-project/types@0.127.0': {} + + '@oxc-project/types@0.151.0': {} + + '@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@pnpm/deps.graph-sequencer@1100.0.1': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@portabletext/toolkit@3.0.3': + dependencies: + '@portabletext/types': 2.0.15 + + '@portabletext/toolkit@5.0.2': + dependencies: + '@portabletext/types': 4.0.2 + + '@portabletext/types@2.0.15': {} + + '@portabletext/types@4.0.2': {} + + '@quansync/fs@1.1.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm-eabi@1.2.10': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-android-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-android-arm64@1.2.10': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.10': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.10': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.10': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.10': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.10': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.10': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.10': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.10': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.10': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.10': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.10': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.10': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.10': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.17': {} + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.4.3': + dependencies: + '@shikijs/primitive': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/primitive@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.4.3': + dependencies: + '@shikijs/types': 4.4.3 + + '@shikijs/types@4.4.3': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@standard-schema/spec@1.1.0': {} + + '@tabby_ai/hijri-converter@1.0.5': {} + + '@tanstack/history@1.161.4': {} + + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.3.0)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.3.0 + + '@tanstack/react-router@1.163.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-store': 0.9.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.163.2 + isbot: 5.2.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.9.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + + '@tanstack/router-core@1.163.2': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.3 + cookie-es: 2.0.1 + seroval: 1.6.7 + seroval-plugins: 1.6.7(seroval@1.6.7) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/store@0.9.3': {} + + '@tiptap/core@3.31.3(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-blockquote@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-bold@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-bubble-menu@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@tiptap/extension-bullet-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-character-count@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-code-block-lowlight@3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + highlight.js: 11.11.2 + lowlight: 3.3.0 + + '@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-code@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + yjs: 13.6.33 + + '@tiptap/extension-document@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-drag-handle-react@3.31.3(@tiptap/extension-drag-handle@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.3)(@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tiptap/extension-drag-handle': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@tiptap/extension-drag-handle@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-collaboration': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-node-range': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + + '@tiptap/extension-dropcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-floating-menu@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + optional: true + + '@tiptap/extension-focus@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-gapcursor@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-hard-break@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-heading@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-horizontal-rule@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-image@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-italic@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-link@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-list-keymap@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-ordered-list@3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-paragraph@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-placeholder@3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-strike@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-subscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-superscript@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-table-cell@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-table-header@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-table-row@3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + + '@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/extension-text-align@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-text@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-typography@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extension-underline@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + + '@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/pm@3.31.3': + dependencies: + prosemirror-changeset: 2.4.3 + prosemirror-commands: 1.7.2 + prosemirror-dropcursor: 1.8.4 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.12 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.5 + + '@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.3 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-floating-menu': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.31.3': + dependencies: + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-bold': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-bullet-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-document': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-gapcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-hard-break': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-heading': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-italic': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list-item': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-list-keymap': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-ordered-list': 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-paragraph': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-strike': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-text': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extensions': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/suggestion@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/pm': 3.31.3 + + '@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)': + dependencies: + lib0: 0.2.117 + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.5 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsesc@2.5.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@26.6.2': + dependencies: + undici-types: 8.9.0 + optional: true + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/use-sync-external-store@0.0.6': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.6.2 + optional: true + + '@ungap/structured-clone@1.4.0': {} + + '@unpic/placeholder@0.1.2': + dependencies: + blurhash: 2.0.5 + + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@wordpress/block-serialization-default-parser@5.56.0': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + am-i-vibing@0.4.0: + dependencies: + process-ancestry: 0.1.0 + + ansis@4.4.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + ast-kit@3.0.0: + dependencies: + '@babel/parser': 8.0.6 + estree-walker: 3.0.3 + pathe: 2.0.3 + + astro-portabletext@0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)): + dependencies: + '@portabletext/toolkit': 3.0.3 + '@portabletext/types': 2.0.15 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + + astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.0 + '@astrojs/internal-helpers': 0.11.0 + '@astrojs/markdown-satteri': 0.4.2 + '@astrojs/telemetry': 3.3.3 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.4.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 5.9.4 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 2.3.2 + esbuild: 0.28.2 + find-proc: 0.2.0 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.4 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + js-yaml: 4.3.2 + jsonc-parser: 3.3.1 + magic-string: 1.4.2 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 9.3.3 + package-manager-detector: 1.8.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + shiki: 4.4.3 + smol-toml: 1.9.0 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + ultrahtml: 1.7.0 + unifont: 0.7.5 + unstorage: 1.17.5 + verkit: 0.4.1 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.5 + optionalDependencies: + sharp: 0.35.4(@types/node@26.6.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + birpc@4.2.0: {} + + blake3-wasm@2.1.5: {} + + blurhash@2.0.5: {} + + body-parser@2.3.0(supports-color@10.2.2): + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3(supports-color@10.2.2) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + bytes@3.1.2: {} + + cac@6.7.14: {} + + cac@7.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + ccount@2.0.1: {} + + chai@6.2.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + citty@0.2.2: {} + + cjs-module-lexer@1.2.3: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + consola@3.4.2: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie-es@2.0.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + croner@10.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@7.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: + optional: true + + date-fns-jalali@4.1.0-0: {} + + date-fns@4.4.0: {} + + dayjs@1.11.23: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + deepmerge@4.3.1: {} + + defu@6.1.7: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.0.2: + optional: true + + detect-libc@2.1.2: {} + + devalue@5.9.4: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.1.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + dompurify@3.4.16: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dset@3.1.4: {} + + dts-resolver@2.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + ee-first@1.1.1: {} + + emdash@0.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3): + dependencies: + '@astrojs/react': 7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@emdash-cms/admin': 0.39.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4) + '@emdash-cms/auth': 0.39.0(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6) + '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/gutenberg-to-portable-text': 0.39.0 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@modelcontextprotocol/sdk': 1.30.1(supports-color@10.2.2)(zod@4.5.4) + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@portabletext/toolkit': 5.0.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@unpic/placeholder': 0.1.2 + arctic: 3.7.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + astro-portabletext: 0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)) + blurhash: 2.0.5 + citty: 0.2.2 + consola: 3.4.2 + croner: 10.0.1 + highlight.js: 11.11.2 + jose: 6.2.12 + jpeg-js: 0.4.4 + jsonc-parser: 3.3.1 + kysely: 0.29.6 + lowlight: 3.3.0 + mime: 4.1.0 + modern-tar: 0.7.7 + picocolors: 1.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + sanitize-html: 2.17.7 + sax: 1.6.1 + smol-toml: 1.9.0 + ulidx: 2.4.1 + upng-js: 2.1.0 + zod: 4.5.4 + optionalDependencies: + '@libsql/kysely-libsql': 0.4.1(kysely@0.29.6) + pg: 8.23.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + - utf-8-validate + + emdash@0.39.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3): + dependencies: + '@astrojs/react': 7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@emdash-cms/admin': 0.39.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4) + '@emdash-cms/auth': 0.39.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6) + '@emdash-cms/blocks': 0.39.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/gutenberg-to-portable-text': 0.39.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@modelcontextprotocol/sdk': 1.30.1(supports-color@10.2.2)(zod@4.5.4) + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@portabletext/toolkit': 5.0.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@unpic/placeholder': 0.1.2 + arctic: 3.7.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + astro-portabletext: 0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)) + blurhash: 2.0.5 + citty: 0.2.2 + consola: 3.4.2 + croner: 10.0.1 + highlight.js: 11.11.2 + jose: 6.2.12 + jpeg-js: 0.4.4 + jsonc-parser: 3.3.1 + kysely: 0.29.6 + lowlight: 3.3.0 + mime: 4.1.0 + modern-tar: 0.7.7 + picocolors: 1.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + sanitize-html: 2.17.7 + sax: 1.6.1 + smol-toml: 1.9.0 + ulidx: 2.4.1 + upng-js: 2.1.0 + zod: 4.5.4 + optionalDependencies: + '@libsql/kysely-libsql': 0.4.1(kysely@0.29.6) + pg: 8.23.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + - utf-8-validate + + empathic@2.1.0: {} + + encodeurl@2.0.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@8.1.0: {} + + error-stack-parser-es@1.0.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + 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 + + 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 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + esm-env@1.2.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventemitter3@5.0.4: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.7.0(express@5.2.1(supports-color@10.2.2))(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + express: 5.2.1(supports-color@10.2.2) + ip-address: 10.7.2 + transitivePeerDependencies: + - supports-color + + express@5.2.1(supports-color@10.2.2): + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0(supports-color@10.2.2) + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@10.2.2) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1(supports-color@10.2.2) + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0(supports-color@10.2.2) + send: 1.2.1(supports-color@10.2.2) + serve-static: 2.2.1(supports-color@10.2.2) + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-equals@5.4.3: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + optional: true + + finalhandler@2.1.1(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-proc@0.2.0: {} + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + optional: true + + forwarded@0.2.0: {} + + framer-motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + get-tsconfig@5.0.0-beta.4: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + gopd@1.2.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + highlight.js@11.11.2: {} + + hono@4.13.9: {} + + hookable@6.1.2: {} + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.1.0 + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-id@4.2.1: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + import-without-cache@0.2.5: {} + + inherits@2.0.4: {} + + ip-address@10.7.2: {} + + ipaddr.js@1.9.1: {} + + iron-webcrypto@1.2.1: {} + + is-docker@4.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.1.0: {} + + is-promise@4.0.0: {} + + isbot@5.2.2: {} + + isexe@2.0.0: {} + + isomorphic.js@0.2.5: {} + + jju@1.4.0: {} + + jose@6.2.12: {} + + jpeg-js@0.4.4: {} + + js-base64@3.9.4: + optional: true + + js-sha256@0.10.1: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + kysely-d1@0.4.0(kysely@0.29.6): + dependencies: + kysely: 0.29.6 + + kysely@0.29.6: {} + + launch-editor@2.14.1: + dependencies: + picocolors: 1.1.1 + shell-quote: 1.10.0 + + launder@1.7.1: + dependencies: + dayjs: 1.11.23 + + layerr@3.0.0: {} + + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + + libsql@0.3.19: + dependencies: + '@neon-rs/load': 0.0.4 + detect-libc: 2.0.2 + optionalDependencies: + '@libsql/darwin-arm64': 0.3.19 + '@libsql/darwin-x64': 0.3.19 + '@libsql/linux-arm64-gnu': 0.3.19 + '@libsql/linux-arm64-musl': 0.3.19 + '@libsql/linux-x64-gnu': 0.3.19 + '@libsql/linux-x64-musl': 0.3.19 + '@libsql/win32-x64-msvc': 0.3.19 + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + 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 + + linkifyjs@4.3.3: {} + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + highlight.js: 11.11.2 + + lru-cache@11.5.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + marked@17.0.6: {} + + math-intrinsics@1.1.0: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@4.1.0: {} + + miniflare@5.20260921.1-alpha(@types/node@26.6.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4(@types/node@26.6.2) + undici: 7.29.0 + workerd: 1.20260921.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + modern-tar@0.7.7: {} + + moo@0.5.3: {} + + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + framer-motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + tslib: 2.8.1 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + nanoid@5.1.16: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-domexception@1.0.0: + optional: true + + node-fetch-native@1.6.7: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + optional: true + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.2.1: {} + + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.12: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + orderedmap@2.1.1: {} + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.3: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.8.0: {} + + pako@1.0.11: {} + + parse-srcset@1.0.2: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: + optional: true + + pg-int8@1.0.1: + optional: true + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + optional: true + + pg-protocol@1.16.0: + optional: true + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + optional: true + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + optional: true + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + optional: true + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: + optional: true + + postgres-bytea@1.0.1: + optional: true + + postgres-date@1.0.7: + optional: true + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + optional: true + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + promise-limit@2.7.0: + optional: true + + property-information@7.2.0: {} + + prosemirror-changeset@2.4.3: + dependencies: + prosemirror-transform: 1.12.1 + + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-dropcursor@1.8.4: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.5 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-view: 1.42.5 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.5 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.12: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.12 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.5 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + prosemirror-view: 1.42.5 + + prosemirror-transform@1.12.1: + dependencies: + prosemirror-model: 1.25.12 + + prosemirror-view@1.42.5: + dependencies: + prosemirror-model: 1.25.12 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.1 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + quansync@1.0.0: {} + + radix3@1.1.2: {} + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-day-picker@9.14.0(react@19.3.0): + dependencies: + '@date-fns/tz': 1.5.0 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.4.0 + date-fns-jalali: 4.1.0-0 + react: 19.3.0 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-hotkeys-hook@5.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + react-image-crop@11.1.2(react@19.3.0): + dependencies: + react: 19.3.0 + + react@19.3.0: {} + + readdirp@5.1.1: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + require-from-string@2.0.2: {} + + reselect@5.3.0: {} + + resolve-pkg-maps@1.0.0: {} + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@5.9.3): + dependencies: + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + ast-kit: 3.0.0 + birpc: 4.2.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.3 + obug: 2.2.1 + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.3): + dependencies: + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + ast-kit: 3.0.0 + birpc: 4.2.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.3 + obug: 2.2.1 + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.0.0-rc.17: + dependencies: + '@oxc-project/types': 0.127.0 + '@rolldown/pluginutils': 1.0.0-rc.17 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.17 + '@rolldown/binding-darwin-x64': 1.0.0-rc.17 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.17 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.17 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.17 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.17 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.17 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.17 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + + rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): + dependencies: + '@oxc-project/types': 0.112.0 + '@rolldown/pluginutils': 1.0.0-rc.3 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-x64': 1.0.0-rc.3 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + rolldown@1.2.10: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.10 + '@rolldown/binding-android-arm64': 1.2.10 + '@rolldown/binding-darwin-arm64': 1.2.10 + '@rolldown/binding-darwin-x64': 1.2.10 + '@rolldown/binding-freebsd-x64': 1.2.10 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.10 + '@rolldown/binding-linux-arm64-gnu': 1.2.10 + '@rolldown/binding-linux-arm64-musl': 1.2.10 + '@rolldown/binding-linux-ppc64-gnu': 1.2.10 + '@rolldown/binding-linux-s390x-gnu': 1.2.10 + '@rolldown/binding-linux-x64-gnu': 1.2.10 + '@rolldown/binding-linux-x64-musl': 1.2.10 + '@rolldown/binding-openharmony-arm64': 1.2.10 + '@rolldown/binding-win32-arm64-msvc': 1.2.10 + '@rolldown/binding-win32-x64-msvc': 1.2.10 + + rope-sequence@1.3.4: {} + + router@2.2.0(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + sanitize-html@2.17.7: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.1.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.28 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + scheduler@0.28.0: {} + + semver@7.8.5: {} + + send@1.2.1(supports-color@10.2.2): + dependencies: + debug: 4.4.3(supports-color@10.2.2) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seroval-plugins@1.6.7(seroval@1.6.7): + dependencies: + seroval: 1.6.7 + + seroval@1.6.7: {} + + serve-static@2.2.1(supports-color@10.2.2): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.35.4(@types/node@26.6.2): + 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 + '@types/node': 26.6.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.10.0: {} + + shiki@4.4.3: + dependencies: + '@shikijs/core': 4.4.3 + '@shikijs/engine-javascript': 4.4.3 + '@shikijs/engine-oniguruma': 4.4.3 + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + '@shikijs/types': 4.4.3 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sisteransi@1.0.5: {} + + smol-toml@1.9.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + split2@4.2.0: + optional: true + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + supports-color@10.2.2: {} + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + tabbable@6.5.0: {} + + tailwind-merge@3.7.0: {} + + tiny-inflate@1.0.3: {} + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinybench@2.9.0: {} + + tinyclip@1.0.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + toidentifier@1.0.1: {} + + tree-kill@1.2.2: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tsdown@0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@5.9.3): + dependencies: + ansis: 4.4.0 + cac: 6.7.14 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.2.5 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@5.9.3) + semver: 7.8.5 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.39 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tsdown@0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.3): + dependencies: + ansis: 4.4.0 + cac: 6.7.14 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.2.5 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.3) + semver: 7.8.5 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.39 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + typescript@6.0.3: {} + + ufo@1.6.4: {} + + ulidx@2.4.1: + dependencies: + layerr: 3.0.0 + + ultrahtml@1.7.0: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.1.0 + quansync: 1.0.0 + + uncrypto@0.1.3: {} + + undici-types@8.9.0: + optional: true + + undici@7.29.0: {} + + undici@8.11.2: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unicode-segmenter@0.17.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.5: + dependencies: + css-tree: 3.2.1 + ohash: 2.0.12 + undici: 8.11.2 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unpipe@1.0.0: {} + + unrun@0.2.39: + dependencies: + rolldown: 1.0.0-rc.17 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + upng-js@2.1.0: + dependencies: + pako: 1.0.11 + + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + + valibot@1.5.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + valibot@1.5.0(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + + vary@1.1.2: {} + + verkit@0.4.1: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.10 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + yaml: 2.9.1 + + vitefu@1.1.3(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + + vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.2 + transitivePeerDependencies: + - msw + + w3c-keyname@2.2.8: {} + + web-streams-polyfill@3.3.3: + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260921.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260921.1 + '@cloudflare/workerd-darwin-arm64': 1.20260921.1 + '@cloudflare/workerd-linux-64': 1.20260921.1 + '@cloudflare/workerd-linux-arm64': 1.20260921.1 + '@cloudflare/workerd-windows-64': 1.20260921.1 + + wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260921.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260921.1-alpha(@types/node@26.6.2) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260921.1 + optionalDependencies: + '@cloudflare/workers-types': 5.20260924.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + ws@8.21.3: + optional: true + + xtend@4.0.2: + optional: true + + xxhash-wasm@1.1.0: {} + + y-protocols@1.0.7(yjs@13.6.33): + dependencies: + lib0: 0.2.117 + yjs: 13.6.33 + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yjs@13.6.33: + dependencies: + lib0: 0.2.117 + + yocto-queue@1.2.2: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod-to-json-schema@3.25.2(zod@4.5.4): + dependencies: + zod: 4.5.4 + + zod@4.4.3: {} + + zod@4.5.4: {} + + zod@4.6.5: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..842183a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,35 @@ +packages: + - "packages/*" + +# esbuild needs its postinstall to link the platform binary (vitest depends +# on it); workerd needs its own, because `@emdash-cms/plugin-test` runs the +# plugin inside the real sandbox rather than a mock. +# +# The key is `allowBuilds`, not `onlyBuiltDependencies`. Measured on pnpm +# 11.15.1 against a throwaway workspace: with `onlyBuiltDependencies: [esbuild]` +# alone the install still ends in ERR_PNPM_IGNORED_BUILDS and no binary is +# linked; with `allowBuilds: { esbuild: true }` the postinstall runs and +# `esbuild --version` works. `emdash-plugin init` writes `allowBuilds` too, +# which is the second witness. The old key is silently ignored, which is the +# worst failure mode available, so it is gone rather than kept "just in case". +allowBuilds: + esbuild: true + workerd: true + +# An ignored build is an error, not a warning. This repo already shipped one +# workspace file whose build settings did nothing; that must not be quiet. +strictDepBuilds: true +dangerouslyAllowAllBuilds: false +minimumReleaseAgeExclude: + - '@emdash-cms/admin@0.39.0' + - '@emdash-cms/auth@0.39.0' + - '@emdash-cms/blocks@0.39.0' + - '@emdash-cms/cloudflare@0.39.0' + - '@emdash-cms/gutenberg-to-portable-text@0.39.0' + - '@emdash-cms/plugin-cli@0.12.0' + - '@emdash-cms/plugin-test@0.2.0' + - '@emdash-cms/plugin-types@0.4.0' + - '@emdash-cms/registry-client@0.6.1' + - '@emdash-cms/registry-lexicons@0.6.0' + - '@emdash-cms/registry-verification@0.3.2' + - emdash@0.39.0 diff --git a/scripts/compat-matrix.sh b/scripts/compat-matrix.sh new file mode 100755 index 0000000..a4228a7 --- /dev/null +++ b/scripts/compat-matrix.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# Run every harness-backed plugin suite against a list of EmDash versions and +# print which combinations pass. +# +# Why this exists: `@emdash-cms/plugin-test` pins `emdash` exactly (0.2.0 pins +# 0.39.0), so out of the box a plugin is only ever tested against the one host +# version the harness chose. pnpm `overrides` rewire the harness's own pinned +# dependency, which is how a range becomes testable at all. +# +# That is a workaround, not a supported path. Nothing stops a future plugin-test +# from importing a symbol that exists only in its pinned host, and the failure +# would look like a plugin bug. The baseline row (no overrides) is therefore not +# optional: it is what tells a harness breakage apart from a plugin regression. +# +# Only packages with a `vitest.config.ts` are run -- those are the ones using +# the workerd harness. `shared` uses plain vitest with no host, and a `link:`ed +# package has no npm versions to override against. +# +# Usage: scripts/compat-matrix.sh [version...] (default: 0.39.1 0.40.0) + +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +VERSIONS=("${@:-}") +[ -z "${VERSIONS[0]:-}" ] && VERSIONS=(0.39.1 0.40.0) + +if [ -n "$(git status --porcelain pnpm-workspace.yaml pnpm-lock.yaml)" ]; then + echo "refusing to run: pnpm-workspace.yaml or pnpm-lock.yaml already modified" >&2 + exit 1 +fi + +PKGS=() +for d in packages/*/; do + [ -f "${d}vitest.config.ts" ] && PKGS+=("$(basename "${d%/}")") +done +[ ${#PKGS[@]} -eq 0 ] && { echo "no harness-backed packages found" >&2; exit 1; } + +restore() { + git checkout -- pnpm-workspace.yaml pnpm-lock.yaml 2>/dev/null || true + pnpm install --no-frozen-lockfile >/dev/null 2>&1 || true +} +trap restore EXIT INT TERM + +# Results accumulate as "label|package|value" lines: macOS ships bash 3.2, +# which has no associative arrays, and this has to run here and in CI. +RESULTS="" +FAILED=0 + +record() { RESULTS="${RESULTS}${1}|${2}|${3} +"; } + +lookup() { + printf '%s' "$RESULTS" | awk -F'|' -v l="$1" -v p="$2" '$1==l && $2==p {print $3; found=1} END {if (!found) print "?"}' | head -1 +} + +run_suites() { + label="$1" + # --no-frozen-lockfile because CI=true makes pnpm imply the opposite, and + # rewriting the lockfile is the entire point of the override rows. + if ! install_out=$(pnpm install --no-frozen-lockfile 2>&1); then + echo " install failed for $label:" + printf '%s\n' "$install_out" | tail -15 | sed 's/^/ /' + for p in "${PKGS[@]}"; do record "$label" "$p" "install-fail"; done + FAILED=1 + return + fi + for p in "${PKGS[@]}"; do + if out=$(cd "packages/$p" && pnpm vitest run 2>&1); then + # `|| true` matters: with `set -eo pipefail` a grep that matches + # nothing would kill the script and hide the reason. + n=$(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -oE 'Tests +[0-9]+ passed' | grep -oE '[0-9]+' | head -1 || true) + if [ -z "$n" ]; then + # The table is cross-checked against the counts published in + # each README, so a missing number is worth a line rather than + # a silent "passed". Show what the summary actually looked like. + echo " note: no test count parsed for $p ($label); summary was:" + printf '%s\n' "$out" | tail -6 | sed 's/^/ /' + fi + record "$label" "$p" "${n:-passed}" + else + echo " $p failed against $label:" + printf '%s\n' "$out" | tail -25 | sed 's/^/ /' + record "$label" "$p" "FAIL" + FAILED=1 + fi + done +} + +echo "baseline (harness's own pinned host, no overrides)" +run_suites "baseline" + +for v in "${VERSIONS[@]}"; do + echo "emdash $v" + git checkout -- pnpm-workspace.yaml pnpm-lock.yaml + cat >> pnpm-workspace.yaml <<-EOF + + overrides: + emdash: $v + "@emdash-cms/cloudflare": $v + "@emdash-cms/blocks": $v + EOF + run_suites "$v" +done + +echo +printf '%-26s %-12s' "package" "baseline" +for v in "${VERSIONS[@]}"; do printf '%-12s' "$v"; done +echo +for p in "${PKGS[@]}"; do + printf '%-26s %-12s' "$p" "$(lookup baseline "$p")" + for v in "${VERSIONS[@]}"; do printf '%-12s' "$(lookup "$v" "$p")"; done + echo +done +echo +echo "numbers are passing tests; FAIL or install-fail means the combination is not supported" + +if [ "$FAILED" -ne 0 ]; then + echo + echo "at least one combination failed" + exit 1 +fi