diff --git a/packages/analytics/.agents/skills b/.agents/skills similarity index 100% rename from packages/analytics/.agents/skills rename to .agents/skills diff --git a/.changeset/analytics-own-repository.md b/.changeset/analytics-own-repository.md new file mode 100644 index 0000000..79bb7e5 --- /dev/null +++ b/.changeset/analytics-own-repository.md @@ -0,0 +1,5 @@ +--- +"@eisbachcode/emdash-plugin-analytics": patch +--- + +The plugin now has a repository of its own: https://github.com/eisbachcode/emdash-plugin-analytics. The old address, `eisbachcode/emdash-plugins`, redirects there. The package's repository, homepage and issue links point to the new address; the plugin itself is unchanged. diff --git a/packages/analytics/.claude/CLAUDE.md b/.claude/CLAUDE.md similarity index 100% rename from packages/analytics/.claude/CLAUDE.md rename to .claude/CLAUDE.md diff --git a/packages/analytics/.claude/skills b/.claude/skills similarity index 100% rename from packages/analytics/.claude/skills rename to .claude/skills diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml index 7ccd111..6156f94 100644 --- a/.github/workflows/compat-matrix.yml +++ b/.github/workflows/compat-matrix.yml @@ -1,12 +1,12 @@ -# Runs every plugin suite against every EmDash version we claim to support. +# Runs the plugin's 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. # # Three jobs, because they answer different questions: # -# tests gating. Typecheck, manifest validation and every suite on the -# EmDash version each package's harness pins. This is the +# tests gating. Typecheck, manifest validation and the suite on the +# EmDash version the harness pins. This is the # baseline that tells a harness breakage from a plugin regression. # declared gating, one job per version so they run in parallel. The # versions inside our published range. A failure here is our @@ -14,7 +14,7 @@ # 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. +# tested-up-to line in the README -- or not to. name: compat matrix on: @@ -49,9 +49,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck - # One package at a time, like the compat script: suites running in - # parallel on one runner are untested. - - run: pnpm --filter "./packages/*" --workspace-concurrency=1 run test + - run: pnpm test declared: name: EmDash ${{ matrix.emdash }} diff --git a/AGENTS.md b/AGENTS.md index 7d9f959..7e51074 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,38 +1,80 @@ -# 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). +# emdash-plugin-analytics + +The EmDash analytics plugin by Eisbachcode: `@eisbachcode/emdash-plugin-analytics` on npm, +`@eisbachcode.de/analytics` in the EmDash registry. + +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` runs the suite +against later EmDash releases. A plain `pnpm install` 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 in + both install modes from EmDash 1.0.1; before that, a `plugins: []` + install gets it undefined there. 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 on every version, with `ui.entry`. +- **MCP schemas never reach the runtime.** `emdash-plugin build` strips + the `mcp` property of `src/plugin.ts` from the bundle and writes the zod + schemas into the manifest as JSON Schema. `src/tools/declare.ts` is + referenced only from there, so zod stays a dev dependency and out of the + bundle; a schema imported by a route handler would pull it back in. The + handlers in `src/tools/load.ts` validate by hand, because the routes are + also reachable over HTTP without the MCP server's validation. +- **An MCP output schema is strict.** Every object becomes + `additionalProperties: false` and the MCP server rejects an answer that + does not match, so a loader's result and its declared output have to + agree key for key. `tests/tools.test.ts` checks each answer against the + schema the build wrote. +- **In `plugins: []`, MCP tools need EmDash 0.42.0 or later.** Older + in-process loaders read tools from the runtime module, where the build + removes them, so a `plugins: []` install on 0.39 to 0.41 lists none. + Sandboxed installs have them on every supported version. +- **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`). ## 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. @@ -41,22 +83,15 @@ peer (see below). ```sh pnpm install pnpm typecheck -pnpm test # emdash-plugin validate, then vitest, per package +pnpm test # emdash-plugin validate, then vitest pnpm build -./scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 0.40.1 0.41.0 0.42.0 1.0.1:0.2.6 # suites against other EmDash releases +./scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 0.40.1 0.41.0 0.42.0 1.0.1:0.2.6 # the suite 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. - -A package that has not been released yet stays `"private": true`. The release -workflow publishes every public package whose version is missing from npm, -changeset or not, so a public package would go out with its first merge to -`main`. The release PR removes the flag and adds the package's first -changeset. That first publish is manual (`pnpm publish` in the package): -npm's trusted-publisher setting can only be added to a package that exists. +change that ships, written for someone upgrading. Listing images go in `images/` and are declared under `release.artifacts.screenshots` in the manifest. Never use a `screenshots/` @@ -64,7 +99,7 @@ folder: the registry bundle takes it whole and refuses a bundle over 256 KB or an image over 128 KB. The registry also refuses a manifest `description` over 140 graphemes, which `emdash-plugin validate` does not check. -Never name a package script `publish`, `version` or `prepare`: npm and pnpm +Never name a script `publish`, `version` or `prepare`: npm and pnpm run scripts with those names on their own during a publish or a version bump. `emdash-plugin init` generates `"publish": "emdash-plugin publish"`, which would push to the EmDash registry after every `npm publish`; here it @@ -73,14 +108,15 @@ is `registry:publish`, and `prepublishOnly` builds before any publish. `.github/workflows/release.yml` does the npm side: with changesets on main it opens a "Version Packages" pull request, and merging that publishes through npm trusted publishing. npm trusts that workflow by its file name, -so renaming it breaks publishing until the setting on npmjs.com changes too. +so renaming it, or the repository, breaks publishing until the setting on +npmjs.com changes too. The registry profile names the repository as well +(`profile setup --repository`). After an npm publish it calls `emdash-release.yml`, generated by `emdash-plugin release setup` and edited since, which publishes the same -versions of every package with an `emdash-plugin.jsonc` to the EmDash -registry. Its build and publish run in separate jobs; only the publish job +version to the EmDash registry. Its build and publish run in separate jobs; only the publish job holds `id-token`, and it installs and builds nothing. The npm publish job runs in the `npm` environment, which the npm trusted publisher requires. Keep both. -The workspace installs with pnpm 11; `allowBuilds` in `pnpm-workspace.yaml` lets esbuild -and workerd run their install scripts, which the test host needs. +The repository 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/packages/analytics/CHANGELOG.md b/CHANGELOG.md similarity index 100% rename from packages/analytics/CHANGELOG.md rename to CHANGELOG.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index ef3f67c..b5f0eee 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,380 @@ -# EmDash plugins +# @eisbachcode/emdash-plugin-analytics -Plugins for [EmDash](https://github.com/emdash-cms/emdash) by [Eisbachcode](https://eisbachcode.de). +Cloudflare Web Analytics on the EmDash dashboard, and next to your content. -| Package | What it does | +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: early releases.** Cloudflare Web Analytics plus demo data: a +> dashboard widget, an Analytics page with a setup check, a per-entry page, +> an Analytics panel in the entry editor and four read-only MCP tools. +> Tested on EmDash 0.39 to 1.0. See "Not in this version". + +The screenshots show demo data on a site made from EmDash's blog template. + +## The dashboard widget + +A **Traffic** card on the dashboard: visits and page views for the last +seven days against the week before, the five most viewed pages with the +entry they belong to, and when the numbers were last synced. + +The Traffic widget: visits and page views for the last 7 days with their change against the previous week, and the five most viewed pages with entry title, path and views + +## The Analytics page + +**Plugins → Analytics** in the admin sidebar, or "Open analytics" on the +dashboard widget. Editors and admins see it. + +The Analytics page over 30 days: visits and page views with their change, a daily chart, top entries with collection, path, views and visits, and tables of referrers and countries + +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. + +The setup check with every row OK: data source, site URL, content index with 8 entries matched, scheduled sync and last sync + +## Analytics per entry + +**Plugins → Analytics per entry**, or "Per entry" on the Analytics page. + +Analytics per entry: every published entry with its collection, path, page views over 7 and 30 days and publish date, most viewed first + +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 + +The Analytics panel in the entry editor: page views over the last 7 and 30 days and the path they are counted at + +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. + +## MCP tools + +EmDash's MCP server can hand the same numbers to an AI agent. The plugin +declares four read-only tools: + +| Tool | Answers | |---|---| -| [`@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 | -| [`@eisbachcode/emdash-plugin-content-freshness`](packages/content-freshness) | A scheduled audit of your content: stale entries, missing SEO descriptions, forgotten drafts, schedules that never fired. Read-only, no network | -| [`@eisbachcode/emdash-plugin-content-link-check`](packages/content-link-check) | A scheduled check of the links in your content: broken links, dead domains and redirects, reported only once they persist. Read-only | +| `analytics__top_entries` | The most viewed published entries over 7 or 30 days, optionally for one collection | +| `analytics__unviewed_entries` | Published entries without a single view in 7 or 30 days | +| `analytics__entry_views` | One entry's 7- and 30-day views, found by id or by path, with its translations and their total | +| `analytics__site_totals` | Site visits and page views over 7, 30 or 90 days, against the period before | + +They read what the sync stored and never call Cloudflare. Every answer +names its window in UTC days, where stored history starts, whether the +content index is complete and when the last sync ran, so an agent can say +that the data is thin instead of presenting it as exact. -## Development +To use them: + +1. Under **Plugins**, open Analytics and turn on **Agent access** after + reviewing the tools. +2. Call them as a user with `plugins:read` (editor and above), through a + token with the `mcp:tools:analytics` scope, or `mcp:tools` for every + plugin's tools. + +After an update that changes a tool, EmDash stops serving the tools until +they are approved again, but the switch stays on. Turn it off and on. + +**Registered in `plugins: []`, the tools need EmDash 0.42 or later.** On +0.39 to 0.41, EmDash's in-process loader looks for tool declarations only in +the plugin's runtime module, where the plugin build leaves them out, so such +a site gets none. Under `sandboxed: []` or installed from the registry, the +tools work on every EmDash version this plugin supports. + +## 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`) | +| URL patterns | each collection needs a **URL Pattern** (under Content Types), or no page can be matched to its entry. Sites made from EmDash's blog template start without them; the setup check says so | +| 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 | -A pnpm workspace. See [AGENTS.md](AGENTS.md) for conventions and checks. +### 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. + +## Install ```sh -pnpm install -pnpm test +pnpm add @eisbachcode/emdash-plugin-analytics +``` + +```js +// astro.config.mjs +import analytics from "@eisbachcode/emdash-plugin-analytics"; + +export default defineConfig({ + integrations: [ + emdash({ + // database, storage, ... + plugins: [analytics], + }), + ], +}); ``` +To run it sandboxed, list it under `sandboxed: [analytics]` instead. That +needs a sandbox runner, which on Cloudflare means the Workers Paid plan; see +EmDash's [plugin sandbox guide](https://docs.emdashcms.com/deployment/plugin-sandbox/). + +## 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. + +The MCP tools declare `plugins:read` like the pages, so an agent acting for +an author gets a permission error from all of them. + +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: + +- **Before EmDash 1.0.1, the widget and the pages stay English in a + `plugins: []` install**, because EmDash passes such a plugin the admin + language there only from 1.0.1 on. Installed from the registry, the + plugin gets it on earlier versions too. 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, see [LICENSE](LICENSE). +MIT. diff --git a/packages/analytics/emdash-plugin.jsonc b/emdash-plugin.jsonc similarity index 99% rename from packages/analytics/emdash-plugin.jsonc rename to emdash-plugin.jsonc index 1e1eb00..eb1318d 100644 --- a/packages/analytics/emdash-plugin.jsonc +++ b/emdash-plugin.jsonc @@ -7,7 +7,7 @@ "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", + "repo": "https://github.com/eisbachcode/emdash-plugin-analytics", // The only version check a user meets before installing. EmDash refuses // an install whose host falls outside this range, so it carries a floor diff --git a/packages/analytics/images/analytics-page.png b/images/analytics-page.png similarity index 100% rename from packages/analytics/images/analytics-page.png rename to images/analytics-page.png diff --git a/packages/analytics/images/banner.png b/images/banner.png similarity index 100% rename from packages/analytics/images/banner.png rename to images/banner.png diff --git a/packages/analytics/images/banner.source.html b/images/banner.source.html similarity index 100% rename from packages/analytics/images/banner.source.html rename to images/banner.source.html diff --git a/packages/analytics/images/editor-panel.png b/images/editor-panel.png similarity index 100% rename from packages/analytics/images/editor-panel.png rename to images/editor-panel.png diff --git a/packages/analytics/images/per-entry.png b/images/per-entry.png similarity index 100% rename from packages/analytics/images/per-entry.png rename to images/per-entry.png diff --git a/packages/analytics/images/setup-check.png b/images/setup-check.png similarity index 100% rename from packages/analytics/images/setup-check.png rename to images/setup-check.png diff --git a/packages/analytics/images/widget.png b/images/widget.png similarity index 100% rename from packages/analytics/images/widget.png rename to images/widget.png diff --git a/package.json b/package.json index b422d2f..a0b39b6 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,75 @@ { - "name": "@eisbachcode/emdash-plugins", - "private": true, + "name": "@eisbachcode/emdash-plugin-analytics", + "version": "0.2.3", + "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-plugin-analytics#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/eisbachcode/emdash-plugin-analytics.git" + }, + "bugs": "https://github.com/eisbachcode/emdash-plugin-analytics/issues", "type": "module", - "description": "EmDash plugins by Eisbachcode", + "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": { - "build": "pnpm run --filter \"./packages/*\" build", - "test": "pnpm run --filter \"./packages/*\" test", - "typecheck": "pnpm run --filter \"./packages/*\" typecheck", + "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", + "registry:login": "emdash-plugin login", + "registry:publish": "emdash-plugin publish", + "release:setup": "emdash-plugin release setup", + "prepublishOnly": "pnpm run build", + "build:astro": "tsdown", "changeset": "changeset", - "version-packages": "changeset version", - "release": "pnpm build && changeset publish" + "version-packages": "changeset version" + }, + "engines": { + "node": ">=22.16" + }, + "peerDependencies": { + "emdash": ">=0.39.0" }, - "packageManager": "pnpm@11.15.1", - "license": "MIT", "devDependencies": { - "@changesets/cli": "^3.0.3" - } + "@changesets/cli": "^3.0.3", + "@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", + "zod": "4.5.4" + }, + "publishConfig": { + "access": "public" + }, + "packageManager": "pnpm@11.15.1" } diff --git a/packages/analytics/.gitignore b/packages/analytics/.gitignore deleted file mode 100644 index b947077..0000000 --- a/packages/analytics/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/packages/analytics/AGENTS.md b/packages/analytics/AGENTS.md deleted file mode 100644 index 0e11947..0000000 --- a/packages/analytics/AGENTS.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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 in - both install modes from EmDash 1.0.1; before that, a `plugins: []` - install gets it undefined there. 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 on every version, with `ui.entry`. -- **MCP schemas never reach the runtime.** `emdash-plugin build` strips - the `mcp` property of `src/plugin.ts` from the bundle and writes the zod - schemas into the manifest as JSON Schema. `src/tools/declare.ts` is - referenced only from there, so zod stays a dev dependency and out of the - bundle; a schema imported by a route handler would pull it back in. The - handlers in `src/tools/load.ts` validate by hand, because the routes are - also reachable over HTTP without the MCP server's validation. -- **An MCP output schema is strict.** Every object becomes - `additionalProperties: false` and the MCP server rejects an answer that - does not match, so a loader's result and its declared output have to - agree key for key. `tests/tools.test.ts` checks each answer against the - schema the build wrote. -- **In `plugins: []`, MCP tools need EmDash 0.42.0 or later.** Older - in-process loaders read tools from the runtime module, where the build - removes them, so a `plugins: []` install on 0.39 to 0.41 lists none. - Sandboxed installs have them on every supported version. -- **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 deleted file mode 100644 index 59e28ba..0000000 --- a/packages/analytics/README.md +++ /dev/null @@ -1,380 +0,0 @@ -# @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: early releases.** Cloudflare Web Analytics plus demo data: a -> dashboard widget, an Analytics page with a setup check, a per-entry page, -> an Analytics panel in the entry editor and four read-only MCP tools. -> Tested on EmDash 0.39 to 1.0. See "Not in this version". - -The screenshots show demo data on a site made from EmDash's blog template. - -## The dashboard widget - -A **Traffic** card on the dashboard: visits and page views for the last -seven days against the week before, the five most viewed pages with the -entry they belong to, and when the numbers were last synced. - -The Traffic widget: visits and page views for the last 7 days with their change against the previous week, and the five most viewed pages with entry title, path and views - -## The Analytics page - -**Plugins → Analytics** in the admin sidebar, or "Open analytics" on the -dashboard widget. Editors and admins see it. - -The Analytics page over 30 days: visits and page views with their change, a daily chart, top entries with collection, path, views and visits, and tables of referrers and countries - -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. - -The setup check with every row OK: data source, site URL, content index with 8 entries matched, scheduled sync and last sync - -## Analytics per entry - -**Plugins → Analytics per entry**, or "Per entry" on the Analytics page. - -Analytics per entry: every published entry with its collection, path, page views over 7 and 30 days and publish date, most viewed first - -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 - -The Analytics panel in the entry editor: page views over the last 7 and 30 days and the path they are counted at - -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. - -## MCP tools - -EmDash's MCP server can hand the same numbers to an AI agent. The plugin -declares four read-only tools: - -| Tool | Answers | -|---|---| -| `analytics__top_entries` | The most viewed published entries over 7 or 30 days, optionally for one collection | -| `analytics__unviewed_entries` | Published entries without a single view in 7 or 30 days | -| `analytics__entry_views` | One entry's 7- and 30-day views, found by id or by path, with its translations and their total | -| `analytics__site_totals` | Site visits and page views over 7, 30 or 90 days, against the period before | - -They read what the sync stored and never call Cloudflare. Every answer -names its window in UTC days, where stored history starts, whether the -content index is complete and when the last sync ran, so an agent can say -that the data is thin instead of presenting it as exact. - -To use them: - -1. Under **Plugins**, open Analytics and turn on **Agent access** after - reviewing the tools. -2. Call them as a user with `plugins:read` (editor and above), through a - token with the `mcp:tools:analytics` scope, or `mcp:tools` for every - plugin's tools. - -After an update that changes a tool, EmDash stops serving the tools until -they are approved again, but the switch stays on. Turn it off and on. - -**Registered in `plugins: []`, the tools need EmDash 0.42 or later.** On -0.39 to 0.41, EmDash's in-process loader looks for tool declarations only in -the plugin's runtime module, where the plugin build leaves them out, so such -a site gets none. Under `sandboxed: []` or installed from the registry, the -tools work on every EmDash version this plugin supports. - -## 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`) | -| URL patterns | each collection needs a **URL Pattern** (under Content Types), or no page can be matched to its entry. Sites made from EmDash's blog template start without them; the setup check says so | -| 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. - -## Install - -```sh -pnpm add @eisbachcode/emdash-plugin-analytics -``` - -```js -// astro.config.mjs -import analytics from "@eisbachcode/emdash-plugin-analytics"; - -export default defineConfig({ - integrations: [ - emdash({ - // database, storage, ... - plugins: [analytics], - }), - ], -}); -``` - -To run it sandboxed, list it under `sandboxed: [analytics]` instead. That -needs a sandbox runner, which on Cloudflare means the Workers Paid plan; see -EmDash's [plugin sandbox guide](https://docs.emdashcms.com/deployment/plugin-sandbox/). - -## 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. - -The MCP tools declare `plugins:read` like the pages, so an agent acting for -an author gets a permission error from all of them. - -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: - -- **Before EmDash 1.0.1, the widget and the pages stay English in a - `plugins: []` install**, because EmDash passes such a plugin the admin - language there only from 1.0.1 on. Installed from the registry, the - plugin gets it on earlier versions too. 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/package.json b/packages/analytics/package.json deleted file mode 100644 index c2fbe48..0000000 --- a/packages/analytics/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@eisbachcode/emdash-plugin-analytics", - "version": "0.2.3", - "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", - "registry:login": "emdash-plugin login", - "registry:publish": "emdash-plugin publish", - "release:setup": "emdash-plugin release setup", - "prepublishOnly": "pnpm run build", - "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", - "zod": "4.5.4" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/content-freshness/.agents/skills b/packages/content-freshness/.agents/skills deleted file mode 120000 index 42c5394..0000000 --- a/packages/content-freshness/.agents/skills +++ /dev/null @@ -1 +0,0 @@ -../skills \ No newline at end of file diff --git a/packages/content-freshness/.claude/CLAUDE.md b/packages/content-freshness/.claude/CLAUDE.md deleted file mode 120000 index be77ac8..0000000 --- a/packages/content-freshness/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../AGENTS.md \ No newline at end of file diff --git a/packages/content-freshness/.claude/skills b/packages/content-freshness/.claude/skills deleted file mode 120000 index 42c5394..0000000 --- a/packages/content-freshness/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../skills \ No newline at end of file diff --git a/packages/content-freshness/.gitignore b/packages/content-freshness/.gitignore deleted file mode 100644 index b947077..0000000 --- a/packages/content-freshness/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/packages/content-freshness/AGENTS.md b/packages/content-freshness/AGENTS.md deleted file mode 100644 index e63c328..0000000 --- a/packages/content-freshness/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# 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` and `@emdash-cms/plugin-test@0.2.3` (EmDash 0.40.1); -`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. diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md deleted file mode 100644 index 24f4a0a..0000000 --- a/packages/content-freshness/README.md +++ /dev/null @@ -1,148 +0,0 @@ -# content-freshness - -Scheduled freshness audit for [EmDash](https://emdashcms.com) content. Finds the -entries that quietly went stale and puts them in one list in the admin. - -What it looks for: - -| Finding | Priority | -| --- | --- | -| Scheduled to publish, or scheduled changes to a published entry, and the date passed without them going live | urgent | -| Published without an SEO description | should fix; nice to fix when the entry has an excerpt-like field | -| Published and untouched for longer than your threshold | should fix | -| Changes saved to a published entry and not published for longer than your threshold | should fix | -| Published although the date in its expiry field has passed (opt-in per collection) | should fix | -| SEO description too short or too long | nice to fix | -| Draft abandoned for longer than your threshold | nice to fix | - -Unpublished changes are counted in collections that keep revisions, and not -while the changes are scheduled to go live. An entry someone saved recently -is not reported as stale: someone is working on it, and if they stop, the -unpublished changes are reported instead. - -Expiry is opt-in per collection, because a site that keeps past events online -as an archive would otherwise get a finding for each of them. When a -collection has a `datetime` field named like `valid_until`, `end_date`, -`expires_at`, `valid_through` or `deadline`, the report suggests it; choose -it, another field, or "Never expires" in the settings. EmDash stores a date -without a time as midnight in the site's timezone, and the finding names that -instant in UTC. An ignored expiry comes back when the date is moved and -passes again. - -Many sites render a description of their own when the SEO panel is empty, -usually from an excerpt or intro field. An entry with a non-empty `excerpt`, -`description`, `summary`, `intro`, `teaser`, `lead`, `subtitle`, or a field -ending in `_excerpt`, `_description`, `_summary` or `_subheadline` therefore -gets the lower priority, and the finding names the field. A site whose -templates always render a description can switch the check off. - -## Where the findings show up - -- **The report** (Content freshness in the admin menu) lists every entry that - needs attention, most urgent first, 25 per page, filtered by collection and - priority. Each entry links straight into its editor. -- **The Freshness panel** in the entry editor shows that entry's findings. It - checks the entry each time it opens, and it is where a finding is set aside: - **Mark as reviewed** holds a stale entry, a forgotten draft or forgotten - unpublished changes back until the threshold runs out again, so a page that - is still correct needs no edit to quiet the report. **Ignore** holds a - description finding, or an expiry on a page kept online as an archive, back - for good. A missed schedule cannot be set aside. **Undo** brings a finding - back. - Authors see the panel on their own entries; the report is for editors and - above. -- **The dashboard widget** counts the entries by priority and says how many - of them are yours. - -The report shows the current state, not a history. An entry is checked again -when it is published, unpublished, scheduled, unscheduled or restored, and -whenever its panel opens; it leaves the report as soon as it is trashed or -deleted. Other edits show up with the next audit, which also removes the rows -of entries that no longer exist. - -The admin follows the administrator's language: English and German. - -Each run audits one page of one collection, and an audit carries on in -follow-up runs until it is done, one per firing of the site's cron trigger, -instead of waiting a day per collection. Every run stays within the ten -subrequests EmDash allows a sandboxed plugin per invocation. - -The report and the dashboard widget also warn about routable collections -without a URL pattern. EmDash then links their entries as -`/{collection}/{slug}` in the sitemap and the admin, which is a 404 on any -site whose routes differ, and nothing else says so. - -## Trust contract - -`content:read` and `schema:read`, nothing else. Every rule is computed from -fields the content and schema APIs already return, so there is **no network -access** and no `content:write`: the plugin reports, it never edits an entry. -What it stores (findings and the findings you set aside) stays in its own -plugin storage. - -## Install - -Config-based, on any EmDash site and any plan: - -```js -// astro.config.mjs -import contentFreshness from "@eisbachcode/emdash-plugin-content-freshness"; - -emdash({ plugins: [contentFreshness] }); -``` - -Or under `sandboxed: []` if the site has a sandbox runner configured. - -Every collection on the site is audited. The nightly audit is registered the -first time the dashboard or the report is opened, so an install needs no -further step. **Audit now** on the report starts one at the next firing of -the site's cron trigger. - -**The site needs a cron trigger.** On Cloudflare that is `"crons"` in -`wrangler.jsonc` plus `scheduled: createScheduledHandler()` on the Worker -entry. Without one, the audit is scheduled and never runs. - -## Settings - -| Setting | Default | | -| --- | --- | --- | -| Stale after | 12 months | For published entries. | -| Draft forgotten after | 6 months | For drafts. | -| Unpublished changes forgotten after | 14 days | For changes saved to a published entry. | -| SEO description | 50–160 characters | Outside this range is a low-priority finding. | -| Report entries without an SEO description | on | Off for a site whose templates always render a description. The length check still runs. | -| Entries per run | 50 | At most 98: D1 binds at most 100 parameters per statement, and storage adds two of its own. The number of calls per run does not grow with it. | -| Schedule | `0 4 * * *` | A cron expression, in UTC. One the scheduler rejects is not saved. | -| Per collection | none | Months for stale entries and forgotten drafts of one collection (empty: the site's value, 0: never), its expiry field, or leave the collection out of the audit. | - -## Develop - -```sh -pnpm install -pnpm test -pnpm typecheck -pnpm build -``` - -## Compatibility - -| EmDash | Plugin test suite | -| ------ | ----------------- | -| 0.40.1 | 119 passed | -| 0.40.0 | 119 passed | -| 0.39.1 | 119 passed | -| 0.39.0 | 119 passed | - -Declared range: `emdash >=0.39.0`, with no upper bound. The table covers -**every EmDash release since the floor**. 0.39.0 is the floor because the -plugin lists collections through `schema:read`, which earlier versions do not -have. - -Reproduce with `scripts/compat-matrix.sh` from the repo root. Last run -2026-09-25, when 0.40.1 was the latest EmDash release. A newer EmDash than the -table's top row is untested rather than unsupported: nothing blocks the install, -and this table is how you tell the difference. - -## Licence - -MIT © Eisbachcode diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc deleted file mode 100644 index 89930a1..0000000 --- a/packages/content-freshness/emdash-plugin.jsonc +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", - - "slug": "content-freshness", - "publisher": "did:plc:iucwrgdd464ddkranaa6ux3l", - - "license": "MIT", - "author": { "name": "Eisbachcode", "url": "https://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" } - }, - "description": "Audits your content: stale entries, weak or missing SEO descriptions, forgotten drafts, schedules that never fired and missing URL patterns.", - "keywords": ["content", "audit", "seo", "maintenance", "editorial"], - - // Trust contract: read content and the collection list, nothing else. - // - // No network at all — every rule is computed from fields the content - // and schema APIs already return. And no `content:write`: this plugin - // reports, it never edits an entry. - "capabilities": ["content:read", "schema:read"], - "allowedHosts": [], - - "storage": { - // One row per entry with at least one finding. `rank` orders the - // report most urgent first; `seenIn` finds the rows a finished sweep - // did not confirm, which belong to entries that no longer exist. - "findings": { "indexes": ["collection", "rank", "seenIn", "authorId", ["collection", "rank"]] }, - // Findings an editor set aside, one document per entry, keyed like its - // finding row. Read by id only. - "dismissals": { "indexes": [] }, - }, - - "admin": { - "pages": [ - { "path": "/report", "label": "Content freshness", "icon": "clock" }, - { "path": "/settings", "label": "Freshness settings", "icon": "settings" }, - ], - "widgets": [{ "id": "summary", "title": "Needs attention", "size": "third" }], - "editorPanels": [{ "id": "freshness", "title": "Freshness", "route": "panel" }], - }, -} diff --git a/packages/content-freshness/package.json b/packages/content-freshness/package.json deleted file mode 100644 index e2586f6..0000000 --- a/packages/content-freshness/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@eisbachcode/emdash-plugin-content-freshness", - "version": "0.1.0", - "private": true, - "description": "EmDash plugin: scheduled freshness audit for your content.", - "keywords": [ - "emdash", - "emdash-plugin", - "cms", - "plugin", - "content", - "audit", - "seo" - ], - "license": "MIT", - "author": "Eisbachcode ", - "homepage": "https://github.com/eisbachcode/emdash-plugins/tree/main/packages/content-freshness#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/eisbachcode/emdash-plugins.git", - "directory": "packages/content-freshness" - }, - "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" - }, - "files": [ - "dist", - "emdash-plugin.jsonc" - ], - "scripts": { - "validate": "emdash-plugin validate", - "build": "emdash-plugin build", - "dev": "emdash-plugin dev", - "typecheck": "tsc --noEmit", - "test": "emdash-plugin validate && vitest run", - "bundle": "emdash-plugin bundle", - "registry:login": "emdash-plugin login", - "registry:publish": "emdash-plugin publish", - "release:setup": "emdash-plugin release setup", - "prepublishOnly": "pnpm run build" - }, - "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.3", - "emdash": ">=0.39.0 <1.0.0", - "typescript": "^5.9.0", - "vitest": "^4.1.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/content-freshness/skills/creating-plugins/SKILL.md b/packages/content-freshness/skills/creating-plugins/SKILL.md deleted file mode 100644 index 336829d..0000000 --- a/packages/content-freshness/skills/creating-plugins/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -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/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts deleted file mode 100644 index 38228da..0000000 --- a/packages/content-freshness/src/check.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Checking one entry now, outside the sweep: after a content state change, - * and whenever its editor panel opens. - */ - -import type { PluginContext } from "emdash/plugin"; - -import { - dismissalFor, - hasRunOut, - partition, - readDismissals, - writeDismissals, - type Dismissal, - type EntryDismissals, -} from "./dismissals.js"; -import { evaluateEntries, forget, storeOne, type EntryFindings, type EntryRef } from "./findings.js"; -import { findingId } from "./ids.js"; -import { datetimeFields, evaluateEntry, type Hit, type Rule } from "./rules.js"; -import { contextFor, readSettings, thresholdsFor } from "./settings.js"; - -export interface PanelAction { - kind: "set-aside" | "undo"; - rule: Rule; - by: string | null; -} - -export interface PanelView { - active: Hit[]; - setAside: Array<{ hit: Hit; dismissal: Dismissal }>; - /** The entry's collection is left out of the audit. */ - skipped?: boolean; -} - -/** - * Apply `action`, check the entry, store its row and return what the panel - * shows. Five `ctx` calls; one more for a changed row, one more for an - * action or an expired review. - */ -export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: PanelAction): Promise { - if (!ctx.content) return { active: [], setAside: [] }; - const [settings, entry, stored, existing, schema] = await Promise.all([ - readSettings(ctx), - ctx.content.get(ref.collection, ref.id), - readDismissals(ctx, ref.collection, ref.id), - ctx.storage.findings.get(findingId(ref.collection, ref.id)) as Promise, - ctx.schema ? ctx.schema.getCollection(ref.collection) : Promise.resolve(null), - ]); - const thresholds = thresholdsFor(settings, ref.collection); - if (!entry || thresholds.skip) { - if (existing) await forget(ctx, ref); - return { active: [], setAside: [], skipped: thresholds.skip }; - } - - const now = new Date(); - const context = contextFor( - settings, - ref.collection, - schema ? { dateFields: datetimeFields(schema.fields), revisions: schema.supports.includes("revisions") } : undefined, - ); - const hits = evaluateEntry(entry, thresholds, now, context); - - const rules = { ...stored?.rules }; - // Reviews that have run out are dropped whenever the entry is checked. - let changed = false; - for (const [rule, dismissal] of Object.entries(rules) as Array<[Rule, Dismissal]>) { - if (hasRunOut(dismissal, now)) { - delete rules[rule]; - changed = true; - } - } - if (action) { - if (action.kind === "undo") { - delete rules[action.rule]; - } else { - const hit = hits.find((candidate) => candidate.rule === action.rule); - const dismissal = dismissalFor(action.rule, thresholds, now, action.by, hit); - if (dismissal) rules[action.rule] = dismissal; - } - changed = true; - } - const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules }; - if (changed) await writeDismissals(ctx, dismissals); - - const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); - const evaluated = evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed, context); - await storeOne(ctx, evaluated, existing); - const { active, setAside } = partition(hits, dismissals, now); - return { active, setAside: setAside.map((hit) => ({ hit, dismissal: dismissals.rules[hit.rule]! })) }; -} - diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts deleted file mode 100644 index 3604cc2..0000000 --- a/packages/content-freshness/src/describe.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * The sentence shown for a hit, built from its stored params in the - * reader's language. - */ - -import { t, type Lang } from "./i18n.js"; -import type { Hit, Severity } from "./rules.js"; - -export function describeHit(lang: Lang, hit: Hit): string { - const { params } = hit; - switch (hit.rule) { - case "overdue-schedule": - return params.kind === "update" - ? t(lang, "hitOverdueUpdate", { date: params.date ?? "" }) - : t(lang, "hitOverduePublish", { date: params.date ?? "" }); - case "missing-description": - return params.field - ? t(lang, "hitMissingFallback", { field: params.field }) - : t(lang, "hitMissingDescription"); - case "description-length": - return t(lang, "hitDescriptionLength", params); - case "stale": - return t(lang, "hitStale", params); - case "stale-draft": - return t(lang, "hitStaleDraft", params); - case "unpublished-changes": - return t(lang, "hitUnpublishedChanges", params); - case "expired": - return t(lang, "hitExpired", params); - } -} - -const SEVERITY_KEY = { high: "priorityHigh", medium: "priorityMedium", low: "priorityLow" } as const; - -export function severityText(lang: Lang, severity: Severity): string { - return t(lang, SEVERITY_KEY[severity]); -} diff --git a/packages/content-freshness/src/dismissals.ts b/packages/content-freshness/src/dismissals.ts deleted file mode 100644 index e357790..0000000 --- a/packages/content-freshness/src/dismissals.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Findings an editor has set aside: one document per entry, under the same - * key as its finding row, so a page of entries needs one `getMany`. - * - * "Mark as reviewed" holds a time-based finding back until the threshold has - * run out again: an imprint that is still correct should not be edited just - * to make the report quiet. "Ignore" holds a description finding, or an - * expiry on a page kept online as an archive, back for good. A missed - * schedule cannot be set aside. - */ - -import type { PluginContext } from "emdash/plugin"; - -import { findingId } from "./ids.js"; -import { DAY_MS, subtractMonths, type Hit, type Rule, type Thresholds } from "./rules.js"; - -export type DismissKind = "review" | "ignore"; - -export const DISMISSIBLE: Partial> = { - stale: "review", - "stale-draft": "review", - "missing-description": "ignore", - "description-length": "ignore", - "unpublished-changes": "review", - "expired": "ignore", -}; - -export interface Dismissal { - /** When the finding may come back; `null` for never. */ - until: string | null; - by: string | null; - at: string; - /** - * What exactly was set aside, for a finding that can recur with other - * values: an ignored expiry names its date, so moving the date forward - * and letting it pass again is reported. - */ - subject?: string; -} - -export interface EntryDismissals { - collection: string; - entryId: string; - rules: Partial>; -} - -/** The part of a hit a dismissal must match, for rules whose finding can recur with other values. */ -function subjectOf(hit: Hit): string | undefined { - return hit.rule === "expired" ? String(hit.params.date) : undefined; -} - -export function hasRunOut(dismissal: Dismissal, now: Date): boolean { - return dismissal.until !== null && Date.parse(dismissal.until) <= now.getTime(); -} - -export function isSetAside(dismissals: EntryDismissals | undefined, hit: Hit, now: Date): boolean { - const dismissal = dismissals?.rules[hit.rule]; - if (!dismissal || hasRunOut(dismissal, now)) return false; - return dismissal.subject === undefined || dismissal.subject === subjectOf(hit); -} - -/** `hits` split into those that stand and those set aside. */ -export function partition(hits: Hit[], dismissals: EntryDismissals | undefined, now: Date) { - const active: Hit[] = []; - const setAside: Hit[] = []; - for (const hit of hits) (isSetAside(dismissals, hit, now) ? setAside : active).push(hit); - return { active, setAside }; -} - -/** A dismissal for `rule`, or null when the rule cannot be set aside. `hit` is the finding being set aside. */ -export function dismissalFor( - rule: Rule, - thresholds: Thresholds, - now: Date, - by: string | null, - hit?: Hit, -): Dismissal | null { - const kind = DISMISSIBLE[rule]; - if (!kind) return null; - if (kind === "ignore") { - const subject = hit ? subjectOf(hit) : undefined; - return { until: null, by, at: now.toISOString(), ...(subject !== undefined ? { subject } : {}) }; - } - if (rule === "unpublished-changes") { - return { until: new Date(now.getTime() + thresholds.pendingDays * DAY_MS).toISOString(), by, at: now.toISOString() }; - } - const months = rule === "stale" ? thresholds.staleMonths : thresholds.draftMonths; - return { until: subtractMonths(now, -months).toISOString(), by, at: now.toISOString() }; -} - -export async function readDismissals( - ctx: PluginContext, - collection: string, - entryId: string, -): Promise { - return ((await ctx.storage.dismissals.get(findingId(collection, entryId))) ?? undefined) as - | EntryDismissals - | undefined; -} - -/** Write `dismissals`, or remove the document once nothing is set aside. */ -export async function writeDismissals(ctx: PluginContext, dismissals: EntryDismissals): Promise { - const id = findingId(dismissals.collection, dismissals.entryId); - if (Object.keys(dismissals.rules).length === 0) await ctx.storage.dismissals.delete(id); - else await ctx.storage.dismissals.put(id, dismissals); -} diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts deleted file mode 100644 index a2e6ec2..0000000 --- a/packages/content-freshness/src/findings.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Stored findings: one row per entry that has at least one hit. - * - * An entry without hits has no row, so a fixed entry leaves the report as - * soon as it is evaluated again. `rank` is the entry's most urgent severity - * and is indexed, which lets the report page through the most urgent rows - * first instead of sorting whatever the first page happened to hold. - */ - -import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -import { partition, type EntryDismissals } from "./dismissals.js"; -import { findingId } from "./ids.js"; -import { evaluateEntry, NO_CONTEXT, worstRank, type EntryContext, type Hit, type Thresholds } from "./rules.js"; - - -export interface EntryFindings { - collection: string; - entryId: string; - slug: string | null; - /** The entry's `title` field, when it has one, for the report's headings. */ - title: string | null; - locale: string | null; - authorId: string | null; - rank: number; - hits: Hit[]; - entryUpdatedAt: string; - /** When the row was written: a sweep's start, or the time of a content hook. */ - seenIn: string; -} - - -export interface Evaluated { - writes: Array<{ id: string; data: EntryFindings }>; - /** Entries evaluated clean, whose rows must go. */ - clears: string[]; -} - -/** - * Rows for `entries`, leaving out findings an editor has set aside. - * `dismissals` is keyed like the rows. - */ -export function evaluateEntries( - collection: string, - entries: PluginContentItem[], - thresholds: Thresholds, - now: Date, - seenIn: string, - dismissals: Map = new Map(), - context: EntryContext = NO_CONTEXT, -): Evaluated { - const result: Evaluated = { writes: [], clears: [] }; - for (const entry of entries) { - const id = findingId(collection, entry.id); - const hits = partition(evaluateEntry(entry, thresholds, now, context), dismissals.get(id), now).active; - if (hits.length === 0) { - result.clears.push(id); - continue; - } - result.writes.push({ - id, - data: { - collection, - entryId: entry.id, - slug: entry.slug, - title: typeof entry.data.title === "string" && entry.data.title.trim() ? entry.data.title.trim() : null, - locale: entry.locale ?? null, - authorId: entry.authorId ?? null, - rank: worstRank(hits), - hits, - entryUpdatedAt: entry.updatedAt, - seenIn, - }, - }); - } - return result; -} - -/** - * Store one entry's result, writing only when it differs from `existing`. - * EmDash reloads an open editor panel after every save, autosaves - * included, and each reload checks the entry: without the comparison every - * keystroke that reached a save would also write a row. `seenIn` is left out - * of the comparison; a row a sweep has not reached yet gets its own write - * when it does. - */ -export async function storeOne(ctx: PluginContext, evaluated: Evaluated, existing: EntryFindings | null): Promise { - const [write] = evaluated.writes; - const [clear] = evaluated.clears; - if (write) { - if (existing && sameFinding(existing, write.data)) return; - await ctx.storage.findings.put(write.id, write.data); - } else if (clear && existing) { - await ctx.storage.findings.delete(clear); - } -} - -function sameFinding(a: EntryFindings, b: EntryFindings): boolean { - const { seenIn: _a, ...left } = a; - const { seenIn: _b, ...right } = b; - return JSON.stringify(left) === JSON.stringify(right); -} - -/** One `putMany` and one `deleteMany` at most, whatever the number of entries. */ -export async function store(ctx: PluginContext, evaluated: Evaluated): Promise { - if (evaluated.writes.length > 0) await ctx.storage.findings.putMany(evaluated.writes); - if (evaluated.clears.length > 0) await ctx.storage.findings.deleteMany(evaluated.clears); -} - -export interface EntryRef { - collection: string; - id: string; -} - -/** - * Remove an entry's row: it was trashed or deleted. What an editor set aside - * goes too once the entry is deleted for good; a trashed entry keeps it, so - * a restore brings the entry back as it was. - */ -export async function forget(ctx: PluginContext, ref: EntryRef, permanent = false): Promise { - const id = findingId(ref.collection, ref.id); - await ctx.storage.findings.delete(id); - if (permanent) await ctx.storage.dismissals.delete(id); -} diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts deleted file mode 100644 index 90d847d..0000000 --- a/packages/content-freshness/src/hooks.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Keeping one entry's row current between sweeps. - * - * Publishing, unpublishing, scheduling and restoring re-evaluate the entry; - * trashing or deleting it removes its row. `content:afterSave` is left out - * on purpose: it also fires for every autosave, which for a sandboxed - * install would start an isolate every few seconds while someone types. - * A save without a state change is picked up when the entry's Freshness - * panel opens, or by the next sweep. - */ - -import type { EntryRef } from "./findings.js"; - -/** The entry a state-change event is about: `{ content, collection }`, with the id on `content`. */ -export function changedEntry(event: unknown): EntryRef | null { - if (typeof event !== "object" || event === null) return null; - const { content, collection } = event as { content?: unknown; collection?: unknown }; - if (typeof collection !== "string" || typeof content !== "object" || content === null) return null; - const id = (content as { id?: unknown }).id; - return typeof id === "string" ? { collection, id } : null; -} - -/** The entry a delete event is about, `{ id, collection, permanent }`, and whether it is gone for good. */ -export function deletedEntry(event: unknown): (EntryRef & { permanent: boolean }) | null { - if (typeof event !== "object" || event === null) return null; - const { id, collection, permanent } = event as { id?: unknown; collection?: unknown; permanent?: unknown }; - return typeof id === "string" && typeof collection === "string" - ? { collection, id, permanent: permanent === true } - : null; -} diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts deleted file mode 100644 index 5e958bf..0000000 --- a/packages/content-freshness/src/i18n.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * 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; `de` is typed against its keys, so a missing - * German entry fails the typecheck. Manifest strings (page labels, widget and - * panel titles) stay static. Logs stay English. - */ - -type Plural = { one: string; other: string }; -type Message = string | Plural; - -const en = { - reportTitle: "Content freshness", - priorityHigh: "Urgent", - priorityMedium: "Should fix", - priorityLow: "Nice to fix", - statEntries: "Entries", - auditRunning: "Audit in progress since {date}", - auditLast: "Last audit {date}", - auditPending: "First audit pending", - nothingTitle: "Nothing needs attention", - nothingText: "No stale entries, no missing descriptions, no overdue schedules.", - noMatch: "No entry matches this filter.", - filterCollection: "Collection", - filterPriority: "Priority", - filterAll: "All", - open: "Open", - nextPage: "Next page", - firstPage: "First page", - auditNow: "Audit now", - auditContinues: "The running audit continues with the next scheduled run.", - auditStarts: "The audit starts with the next scheduled run.", - urlPatternTitle: "No URL pattern: {names}", - urlPatternDetail: - "EmDash links their entries as /{collection}/{slug} in the sitemap and the admin, which is wrong unless the site uses exactly those routes. Set a URL pattern under Content Types, or switch off routing for a collection that has no pages of its own.", - urlPatternShort: "Sitemap links for these collections may be broken.", - - widgetClear: "Everything current", - widgetLastAudit: "Last audit {date}.", - widgetPending: "Runs on the next scheduled audit.", - widgetYours: { one: "{count} of them is yours", other: "{count} of them are yours" }, - openReport: "Open report", - - panelClear: "Nothing to fix here.", - panelSkipped: "This collection is left out of the freshness audit.", - markReviewed: "Mark as reviewed", - ignore: "Ignore", - undo: "Undo", - reviewedUntil: "Marked as reviewed; checked again from {date}.", - ignored: "Ignored.", - dismissedHeader: "Set aside", - - settingsTitle: "Freshness settings", - settingsIntro: "These thresholds apply to every collection unless a collection sets its own at the end of the form.", - staleMonths: "Months before a published entry counts as stale", - draftMonths: "Months before a draft counts as forgotten", - descriptionMin: "Shortest acceptable SEO description", - descriptionMax: "Longest acceptable SEO description", - reportMissing: "Report entries without an SEO description", - reportMissingHelp: "Switch off if your templates always render a description of their own.", - pageSize: "Entries per run", - schedule: "Schedule (cron, UTC)", - pendingDays: "Days before unpublished changes to a published entry count as forgotten", - collectionExpiry: "{collection}: expires after the date in", - expiryUnset: "Not checked", - expirySuggested: "{field} (suggested)", - expiryOff: "Never expires", - expirySuggestTitle: "Expiry dates not checked: {names}", - expirySuggestText: - "These collections have a date field that looks like an expiry date. Choose it in the Freshness settings to report entries still published after it, or choose “Never expires”.", - openSettings: "Open settings", - collectionStale: "{collection}: months before an entry is stale (empty: {months}, 0: never)", - collectionDraft: "{collection}: months before a draft is forgotten (empty: {months}, 0: never)", - collectionSkip: "{collection}: leave out of the audit", - save: "Save", - saved: "Settings saved.", - notSaved: "Settings not saved. {reason}", - adminOnly: "Only an administrator can change these settings.", - - hitOverduePublish: "Scheduled to go live on {date}, but it never did.", - hitOverdueUpdate: "Changes scheduled for {date} were never published.", - hitMissingDescription: "Published without an SEO description.", - hitMissingFallback: "No SEO description; templates often show the “{field}” field instead.", - hitDescriptionLength: "SEO description is {length} characters; aim for {min}–{max}.", - hitStale: "Not touched since {since}.", - hitStaleDraft: "Draft untouched since {since}.", - hitUnpublishedChanges: "Changes saved on {since} are not published yet.", - hitExpired: "{field} was {date}, and the entry is still published.", -} satisfies Record; - -export type MessageKey = keyof typeof en; - -const de: Record = { - reportTitle: "Aktualität der Inhalte", - priorityHigh: "Dringend", - priorityMedium: "Bald erledigen", - priorityLow: "Bei Gelegenheit", - statEntries: "Einträge", - auditRunning: "Prüfung läuft seit {date}", - auditLast: "Letzte Prüfung {date}", - auditPending: "Erste Prüfung steht aus", - nothingTitle: "Alles aktuell", - nothingText: "Keine veralteten Einträge, keine fehlenden Beschreibungen, keine verpassten Termine.", - noMatch: "Kein Eintrag passt zu diesem Filter.", - filterCollection: "Kollektion", - filterPriority: "Priorität", - filterAll: "Alle", - open: "Öffnen", - nextPage: "Nächste Seite", - firstPage: "Erste Seite", - auditNow: "Jetzt prüfen", - auditContinues: "Die laufende Prüfung geht mit dem nächsten geplanten Lauf weiter.", - auditStarts: "Die Prüfung startet mit dem nächsten geplanten Lauf.", - urlPatternTitle: "Kein URL-Muster: {names}", - urlPatternDetail: - "EmDash verlinkt ihre Einträge in der Sitemap und im Admin als /{collection}/{slug}. Das stimmt nur, wenn die Website genau diese Routen verwendet. Lege unter Inhaltstypen ein URL-Muster fest oder schalte das Routing für eine Kollektion ohne eigene Seiten ab.", - urlPatternShort: "Sitemap-Links dieser Kollektionen sind möglicherweise falsch.", - - widgetClear: "Alles aktuell", - widgetLastAudit: "Letzte Prüfung {date}.", - widgetPending: "Läuft mit der nächsten geplanten Prüfung.", - widgetYours: { one: "{count} davon ist deiner", other: "{count} davon sind deine" }, - openReport: "Bericht öffnen", - - panelClear: "Hier gibt es nichts zu tun.", - panelSkipped: "Diese Kollektion ist von der Prüfung ausgenommen.", - markReviewed: "Als geprüft markieren", - ignore: "Ignorieren", - undo: "Rückgängig", - reviewedUntil: "Als geprüft markiert; wird ab {date} wieder geprüft.", - ignored: "Ignoriert.", - dismissedHeader: "Zurückgestellt", - - settingsTitle: "Einstellungen zur Aktualität", - settingsIntro: - "Diese Schwellen gelten für alle Kollektionen, außer eine Kollektion legt am Ende des Formulars eigene fest.", - staleMonths: "Monate, bis ein veröffentlichter Eintrag als veraltet gilt", - draftMonths: "Monate, bis ein Entwurf als vergessen gilt", - descriptionMin: "Kürzeste akzeptable SEO-Beschreibung", - descriptionMax: "Längste akzeptable SEO-Beschreibung", - reportMissing: "Einträge ohne SEO-Beschreibung melden", - reportMissingHelp: "Schalte das ab, wenn deine Templates immer eine eigene Beschreibung ausgeben.", - pageSize: "Einträge pro Lauf", - schedule: "Zeitplan (Cron, UTC)", - pendingDays: "Tage, bis unveröffentlichte Änderungen an einem veröffentlichten Eintrag als vergessen gelten", - collectionExpiry: "{collection}: läuft ab nach dem Datum in", - expiryUnset: "Nicht geprüft", - expirySuggested: "{field} (vorgeschlagen)", - expiryOff: "Läuft nie ab", - expirySuggestTitle: "Ablaufdaten nicht geprüft: {names}", - expirySuggestText: - "Diese Kollektionen haben ein Datumsfeld, das nach einem Ablaufdatum aussieht. Wähle es in den Einstellungen zur Aktualität, um Einträge zu melden, die danach noch veröffentlicht sind, oder wähle „Läuft nie ab“.", - openSettings: "Einstellungen öffnen", - collectionStale: "{collection}: Monate, bis ein Eintrag veraltet ist (leer: {months}, 0: nie)", - collectionDraft: "{collection}: Monate, bis ein Entwurf vergessen ist (leer: {months}, 0: nie)", - collectionSkip: "{collection}: von der Prüfung ausnehmen", - save: "Speichern", - saved: "Einstellungen gespeichert.", - notSaved: "Einstellungen nicht gespeichert. {reason}", - adminOnly: "Nur Administratoren können diese Einstellungen ändern.", - - hitOverduePublish: "Sollte am {date} live gehen, ist es aber nie.", - hitOverdueUpdate: "Die für {date} geplanten Änderungen wurden nie veröffentlicht.", - hitMissingDescription: "Veröffentlicht ohne SEO-Beschreibung.", - hitMissingFallback: "Keine SEO-Beschreibung; Templates zeigen oft stattdessen das Feld „{field}“.", - hitDescriptionLength: "Die SEO-Beschreibung hat {length} Zeichen; empfohlen sind {min}–{max}.", - hitStale: "Seit {since} nicht bearbeitet.", - hitStaleDraft: "Entwurf seit {since} nicht bearbeitet.", - hitUnpublishedChanges: "Am {since} gespeicherte Änderungen sind noch nicht veröffentlicht.", - hitExpired: "{field} war am {date}, der Eintrag ist aber noch veröffentlicht.", -}; - -const catalogues = { en, de } as const; - -export type Lang = keyof typeof catalogues; -export type Params = Record; - -/** The catalogue for an admin locale: `de`, `de-AT` and `de-CH` read German, everything else English. */ -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: 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)); -} diff --git a/packages/content-freshness/src/ids.ts b/packages/content-freshness/src/ids.ts deleted file mode 100644 index 17f80f8..0000000 --- a/packages/content-freshness/src/ids.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Storage keys and the size of one storage call. - */ - -/** - * Most ids one storage call may carry. - * - * In-process, `getMany` and `deleteMany` build one `IN (...)` that also binds - * the plugin id and the collection name, and D1 binds at most 100 parameters - * per statement. The sandbox bridge does the same for `getMany`. Every call - * that takes a page of ids therefore stays at 98, which is why a run audits - * at most 98 entries and a cleanup run removes at most 98 rows. - */ -export const ID_BATCH = 98; - -/** The key of an entry's finding row and of its dismissals document. */ -export function findingId(collection: string, entryId: string): string { - return `${collection}:${entryId}`; -} diff --git a/packages/content-freshness/src/panel.ts b/packages/content-freshness/src/panel.ts deleted file mode 100644 index 69e5a20..0000000 --- a/packages/content-freshness/src/panel.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * The entry editor's Freshness panel. - * - * It checks the entry each time it opens, so a finding fixed with a plain - * save, which no content hook reports, leaves the report as soon as someone - * looks at the entry again. It is also where findings are set aside. - */ - -import type { Block, BlockResponse } from "@emdash-cms/blocks"; - -import type { PanelView } from "./check.js"; -import { describeHit, severityText } from "./describe.js"; -import { DISMISSIBLE } from "./dismissals.js"; -import { t, type Lang } from "./i18n.js"; -import { RULES, type Rule } from "./rules.js"; - -export const PANEL_ID = "freshness"; -export const SET_ASIDE_ACTION = "set_aside"; -export const UNDO_ACTION = "undo"; - -/** The rule a panel button carries, if it names one. */ -export function ruleOf(value: unknown): Rule | null { - if (typeof value !== "object" || value === null) return null; - const rule = (value as { rule?: unknown }).rule; - return RULES.find((known) => known === rule) ?? null; -} - -export function renderPanel(lang: Lang, view: PanelView): BlockResponse { - if (view.skipped) return { blocks: [{ type: "context", text: t(lang, "panelSkipped") }] }; - if (view.active.length === 0 && view.setAside.length === 0) { - return { blocks: [{ type: "context", text: t(lang, "panelClear") }] }; - } - - const blocks: Block[] = view.active.map((hit) => { - const kind = DISMISSIBLE[hit.rule]; - return { - type: "section", - text: `${severityText(lang, hit.severity)}: ${describeHit(lang, hit)}`, - ...(kind - ? { - accessory: { - type: "button" as const, - action_id: SET_ASIDE_ACTION, - label: t(lang, kind === "review" ? "markReviewed" : "ignore"), - value: { rule: hit.rule }, - }, - } - : {}), - }; - }); - - if (view.setAside.length > 0) { - blocks.push({ type: "context", text: t(lang, "dismissedHeader") }); - for (const { hit, dismissal } of view.setAside) { - const until = dismissal.until - ? t(lang, "reviewedUntil", { date: dismissal.until.slice(0, 10) }) - : t(lang, "ignored"); - blocks.push({ - type: "section", - text: `${describeHit(lang, hit)} ${until}`, - accessory: { type: "button", action_id: UNDO_ACTION, label: t(lang, "undo"), value: { rule: hit.rule } }, - }); - } - } - return { blocks }; -} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts deleted file mode 100644 index 8a032fd..0000000 --- a/packages/content-freshness/src/plugin.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * content-freshness — scheduled staleness audit for EmDash content. - * - * Standard plugin format: one entry point for both trusted (in-process, - * `plugins: []`) and sandboxed mode. Logic lives in the sibling modules. - */ - -import { hasRole, listCollections, ROLE } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; - -import { checkEntry, type PanelAction } from "./check.js"; -import { forget } from "./findings.js"; -import { changedEntry, deletedEntry } from "./hooks.js"; -import { langOf, t, type Lang } from "./i18n.js"; -import { - AUDIT_NOW_ACTION, - buildReportPage, - buildSettingsPage, - buildWidget, - countFindings, - SAVE_SETTINGS_ACTION, - VIEW_ACTION, - viewFrom, -} from "./report.js"; -import { renderPanel, ruleOf, SET_ASIDE_ACTION, UNDO_ACTION } from "./panel.js"; -import { ensureScheduled } from "./schedule.js"; -import { applyForm, readSettings, readStoredSettings, writeStoredSettings } from "./settings.js"; -import { readState } from "./state.js"; -import { AUDIT_NOW_TASK, runAudit } from "./sweep.js"; - -interface AdminInteraction { - type: string; - page?: string; - action_id?: string; - value?: unknown; - values?: Record; -} - -/** - * Settings and state, with the recurring audit registered if it was not yet. - * A failure to schedule is logged and the page still renders: the settings - * page is where a bad schedule gets fixed. - */ -async function loadAdmin(ctx: PluginContext) { - const [stored, state] = await Promise.all([readStoredSettings(ctx), readState(ctx)]); - try { - if (await ensureScheduled(ctx, stored)) await writeStoredSettings(ctx, stored); - } catch (error) { - ctx.log.warn(`Could not schedule the audit: ${errorMessage(error)}`); - } - return { settings: stored.settings, state }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - - -/** Re-evaluate one entry after a state change. Never fails the editor's action. */ -async function refresh(ctx: PluginContext, event: unknown): Promise { - const ref = changedEntry(event); - if (!ref) return; - try { - await checkEntry(ctx, ref); - } catch (error) { - ctx.log.warn(`Could not re-check ${ref.collection}/${ref.id}: ${String(error)}`); - } -} - -const onStateChange = { - errorPolicy: "continue" as const, - handler: async (event: unknown, ctx: PluginContext) => refresh(ctx, event), -}; - -const plugin = { - hooks: { - "plugin:activate": { - handler: async (_event, ctx) => { - const stored = await readStoredSettings(ctx); - await ensureScheduled(ctx, stored, true); - await writeStoredSettings(ctx, stored); - }, - }, - - /** - * The recurring task, the one-shot the "Audit now" button schedules, - * and the follow-ups of a running sweep. - * - * The recurring task and the button's one-shot cannot share a name. - * `ctx.cron.schedule()` upserts on (plugin, task name), so scheduling - * the recurring name with a one-shot timestamp would replace the - * recurring audit with a task that runs once and never again. - */ - cron: { - handler: async (event, ctx) => { - await runAudit(ctx, event); - }, - }, - - "content:afterPublish": onStateChange, - "content:afterUnpublish": onStateChange, - "content:afterSchedule": onStateChange, - "content:afterUnschedule": onStateChange, - "content:afterRestore": onStateChange, - "content:afterDelete": { - errorPolicy: "continue", - handler: async (event, ctx) => { - const ref = deletedEntry(event); - if (!ref) return; - try { - await forget(ctx, ref, ref.permanent); - } catch (error) { - ctx.log.warn(`Could not drop ${ref.collection}/${ref.id}: ${String(error)}`); - } - }, - }, - }, - - routes: { - admin: { - // Without this the route resolves with no permission and dispatch - // falls back to `plugins:manage`, which is administrators only, so - // every editor sees the widget card and a permission error inside - // it. `plugins:read` is editor and above. Settings submissions are - // held to administrators inside the handler, because a route carries - // one permission and this one serves both a report and a form. - permission: "plugins:read", - handler: async (routeCtx, ctx) => { - const interaction = routeCtx.input as AdminInteraction; - const lang = langOf(routeCtx.ui?.locale); - - if (interaction.type === "page_load") { - const { settings, state } = await loadAdmin(ctx); - if (interaction.page === "widget:summary") return buildWidget(ctx, state, lang, routeCtx.user?.id); - if (interaction.page === "/settings") return buildSettingsPage(settings, lang, await listCollections(ctx)); - return buildReportPage(ctx, state, settings, lang); - } - - if (interaction.type === "form_submit" && interaction.action_id === SAVE_SETTINGS_ACTION) { - return saveSettings(ctx, routeCtx.user, interaction.values ?? {}, lang); - } - - if (interaction.type === "block_action") { - if (interaction.action_id === VIEW_ACTION) { - const [state, settings] = await Promise.all([readState(ctx), readSettings(ctx)]); - return buildReportPage(ctx, state, settings, lang, viewFrom(interaction.value)); - } - if (interaction.action_id === AUDIT_NOW_ACTION) { - await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); - const [state, settings] = await Promise.all([readState(ctx), readSettings(ctx)]); - return { - ...(await buildReportPage(ctx, state, settings, lang, viewFrom(interaction.value))), - toast: { message: t(lang, state.sweep ? "auditContinues" : "auditStarts"), type: "info" }, - }; - } - } - - return { blocks: [] }; - }, - }, - - // The entry editor's Freshness panel. Its own route because it needs a - // lower permission than the report: `content:edit_own` is author and - // above, and the host also checks that the user may edit this entry, - // so an author sets aside findings on their own entries only. - panel: { - permission: "content:edit_own", - handler: async (routeCtx, ctx) => { - const ui = routeCtx.ui; - if (ui?.surface !== "content-editor-panel") return { blocks: [] }; - const input = routeCtx.input as { type?: string; action_id?: string; value?: unknown }; - const rule = ruleOf(input.value); - const kind = - input.action_id === SET_ASIDE_ACTION ? "set-aside" : input.action_id === UNDO_ACTION ? "undo" : null; - const action: PanelAction | undefined = - input.type === "block_action" && rule && kind ? { kind, rule, by: routeCtx.user?.id ?? null } : undefined; - const view = await checkEntry(ctx, { collection: ui.entry.collection, id: ui.entry.id }, action); - return renderPanel(langOf(ui.locale), view); - }, - }, - - status: { - handler: async (_routeCtx, ctx) => { - const [state, counts] = await Promise.all([readState(ctx), countFindings(ctx)]); - return { - lastSweepFinishedAt: state.lastFinishedAt, - sweepInProgress: state.sweep !== null, - entriesBySeverity: counts, - }; - }, - }, - }, -} satisfies SandboxedPlugin; - -async function saveSettings( - ctx: PluginContext, - user: Parameters[0], - values: Record, - lang: Lang, -) { - const [stored, collections] = await Promise.all([readStoredSettings(ctx), listCollections(ctx)]); - if (!hasRole(user, ROLE.ADMIN)) { - return { - ...buildSettingsPage(stored.settings, lang, collections), - toast: { message: t(lang, "adminOnly"), type: "error" as const }, - }; - } - - const shown = collections.map((collection) => collection.slug); - const next = { settings: applyForm(stored.settings, values, shown), scheduledAs: stored.scheduledAs }; - // Schedule first: an expression the scheduler rejects is never stored. - try { - await ensureScheduled(ctx, next); - } catch (error) { - return { - ...buildSettingsPage(stored.settings, lang, collections), - toast: { message: t(lang, "notSaved", { reason: errorMessage(error) }), type: "error" as const }, - }; - } - await writeStoredSettings(ctx, next); - return { - ...buildSettingsPage(next.settings, lang, collections), - toast: { message: t(lang, "saved"), type: "success" as const }, - }; -} - -/** - * `satisfies` above for the per-hook inference (`event` typed by hook - * name), and an explicit type on the export because the inferred shape - * cannot be named from a pnpm nested path — `tsc` rejects the generated - * declaration with TS2742 otherwise. - */ -export default plugin as SandboxedPlugin; diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts deleted file mode 100644 index 06c7996..0000000 --- a/packages/content-freshness/src/report.ts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * Block Kit surfaces: report page, settings form, dashboard widget. - * - * Every block is typed with upstream's own interfaces, imported as types - * only so nothing of `@emdash-cms/blocks` reaches the plugin bundle. The - * host rejects a sandboxed response whose blocks do not validate, so a - * wrong key must be a compile error. - */ - -import type { - ActionsBlock, - BannerBlock, - Block, - BlockResponse, - ButtonElement, - FormBlock, - FormField, - SelectElement, - StatsBlock, -} from "@emdash-cms/blocks"; -import { listCollections, type PluginCollectionInfo } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -import { describeHit } from "./describe.js"; -import type { EntryFindings } from "./findings.js"; -import { ID_BATCH } from "./ids.js"; -import { t, type Lang, type MessageKey } from "./i18n.js"; -import { collectionsWithoutUrlPattern } from "./routing.js"; -import { datetimeFields, detectExpiryField, RANK } from "./rules.js"; -import { - collectionField, - EXPIRY_OFF, - EXPIRY_UNSET, - suggestedExpiry, - thresholdsFor, - type Settings, -} from "./settings.js"; -import type { State } from "./state.js"; - -export const VIEW_ACTION = "report_view"; -export const AUDIT_NOW_ACTION = "audit_now"; -export const SAVE_SETTINGS_ACTION = "save_settings"; - -/** Entries per report page. Three blocks each keeps a page well inside Block Kit's limits. */ -const PAGE_ENTRIES = 25; - -const PRIORITY: Record = { - [RANK.high]: "priorityHigh", - [RANK.medium]: "priorityMedium", - [RANK.low]: "priorityLow", -}; - -/** - * What the report shows. A block action carries only its own value, so every - * control that changes the view carries the whole view: a filter keeps the - * other filter, and paging keeps both. - */ -export interface ReportView { - collection: string | null; - rank: number | null; - cursor: string | null; -} - -export const FIRST_VIEW: ReportView = { collection: null, rank: null, cursor: null }; - -/** The view a control sent: a JSON string from a select, an object from a button. */ -export function viewFrom(value: unknown): ReportView { - let source: unknown = value; - if (typeof value === "string") { - try { - source = JSON.parse(value); - } catch { - return FIRST_VIEW; - } - } - if (typeof source !== "object" || source === null) return FIRST_VIEW; - const { collection, rank, cursor } = source as Record; - return { - collection: typeof collection === "string" && collection ? collection : null, - rank: typeof rank === "number" && Object.hasOwn(PRIORITY, rank) ? rank : null, - cursor: typeof cursor === "string" && cursor ? cursor : null, - }; -} - -function viewValue(view: ReportView): string { - return JSON.stringify({ collection: view.collection, rank: view.rank, cursor: view.cursor }); -} - -/** Entries by the severity of their most urgent finding. */ -export interface Counts { - high: number; - medium: number; - low: number; - total: number; -} - -export async function countFindings(ctx: PluginContext): Promise { - const [high, medium, low] = await Promise.all( - [RANK.high, RANK.medium, RANK.low].map((rank) => ctx.storage.findings.count({ rank })), - ); - return { high: high ?? 0, medium: medium ?? 0, low: low ?? 0, total: (high ?? 0) + (medium ?? 0) + (low ?? 0) }; -} - -function statsBlock(lang: Lang, counts: Counts, withTotal: boolean): StatsBlock { - return { - type: "stats", - items: [ - { label: t(lang, "priorityHigh"), value: counts.high }, - { label: t(lang, "priorityMedium"), value: counts.medium }, - { label: t(lang, "priorityLow"), value: counts.low }, - ...(withTotal ? [{ label: t(lang, "statEntries"), value: counts.total }] : []), - ], - }; -} - -function auditStatus(lang: Lang, state: State): string { - if (state.sweep) return t(lang, "auditRunning", { date: state.sweep.startedAt.slice(0, 10) }); - if (state.lastFinishedAt) return t(lang, "auditLast", { date: state.lastFinishedAt.slice(0, 10) }); - return t(lang, "auditPending"); -} - -/** A warning about collections whose public URLs EmDash gets wrong, or nothing. */ -function urlPatternBanner(lang: Lang, collections: PluginCollectionInfo[], detail: boolean): BannerBlock[] { - const missing = collectionsWithoutUrlPattern(collections); - if (missing.length === 0) return []; - const names = missing.map((collection) => collection.label || collection.slug).join(", "); - return [ - { - type: "banner", - variant: "alert", - title: t(lang, "urlPatternTitle", { names }), - description: t(lang, detail ? "urlPatternDetail" : "urlPatternShort"), - }, - ]; -} - -/** The dashboard card. With `userId`, it also says how many of the entries are that user's. */ -export async function buildWidget( - ctx: PluginContext, - state: State, - lang: Lang, - userId?: string, -): Promise { - const [counts, collections] = await Promise.all([countFindings(ctx), listCollections(ctx)]); - const banner = urlPatternBanner(lang, collections, false); - - if (counts.total === 0) { - if (banner.length > 0) return { blocks: banner }; - return { - blocks: [ - { - type: "empty", - title: t(lang, state.lastFinishedAt ? "widgetClear" : "auditPending"), - description: state.lastFinishedAt - ? t(lang, "widgetLastAudit", { date: state.lastFinishedAt.slice(0, 10) }) - : t(lang, "widgetPending"), - }, - ], - }; - } - - const yours = userId ? await ctx.storage.findings.count({ authorId: userId }) : 0; - return { - blocks: [ - ...banner, - statsBlock(lang, counts, false), - ...(yours > 0 ? [{ type: "context" as const, text: t(lang, "widgetYours", { count: yours }) }] : []), - { type: "context", text: auditStatus(lang, state) }, - { - type: "actions", - elements: [ - { type: "link", label: t(lang, "openReport"), target: { kind: "plugin-page", path: "/report" } }, - ], - }, - ], - }; -} - -/** - * A page of rows for `view`, most urgent first, and whether it is a later - * page. - * - * Storage continues from the cursor row's current rank. When that row has - * gone since the previous page, because its entry was fixed or deleted, - * nothing follows it and the page comes back empty. That, and a cursor - * storage cannot decode, fall back to the first page of the same filter. - */ -async function findingsPage(ctx: PluginContext, view: ReportView) { - const where: Record = {}; - if (view.collection) where.collection = view.collection; - if (view.rank !== null) where.rank = view.rank; - const query = { where, orderBy: { rank: "asc" as const }, limit: PAGE_ENTRIES }; - if (view.cursor) { - try { - const page = await ctx.storage.findings.query({ ...query, cursor: view.cursor }); - if (page.items.length > 0) return { page, later: true }; - } catch { - // Undecodable cursor. - } - } - return { page: await ctx.storage.findings.query(query), later: false }; -} - -function filters(lang: Lang, view: ReportView, collections: PluginCollectionInfo[]): ActionsBlock { - const collection: SelectElement = { - type: "select", - action_id: VIEW_ACTION, - label: t(lang, "filterCollection"), - initial_value: viewValue({ ...view, cursor: null }), - options: [ - { label: t(lang, "filterAll"), value: viewValue({ ...view, collection: null, cursor: null }) }, - ...collections.map((item) => ({ - label: item.label || item.slug, - value: viewValue({ ...view, collection: item.slug, cursor: null }), - })), - ], - }; - const priority: SelectElement = { - type: "select", - action_id: VIEW_ACTION, - label: t(lang, "filterPriority"), - initial_value: viewValue({ ...view, cursor: null }), - options: [ - { label: t(lang, "filterAll"), value: viewValue({ ...view, rank: null, cursor: null }) }, - ...[RANK.high, RANK.medium, RANK.low].map((rank) => ({ - label: t(lang, PRIORITY[rank]!), - value: viewValue({ ...view, rank, cursor: null }), - })), - ], - }; - return { type: "actions", elements: [collection, priority] }; -} - -function entryBlocks(lang: Lang, row: EntryFindings, collectionLabel: string, withLocale: boolean): Block[] { - const name = row.title ?? row.slug ?? row.entryId; - const where = [name, collectionLabel, ...(withLocale && row.locale ? [row.locale] : [])].join(" · "); - const findings = row.hits.map((hit) => describeHit(lang, hit)).join(" "); - return [ - { - type: "section", - text: where, - accessory: { - type: "link", - label: t(lang, "open"), - target: { - kind: "content", - collection: row.collection, - id: row.entryId, - ...(row.locale ? { locale: row.locale } : {}), - }, - }, - }, - { type: "context", text: `${t(lang, PRIORITY[row.rank] ?? "priorityLow")}: ${findings}` }, - { type: "divider" }, - ]; -} - -export async function buildReportPage( - ctx: PluginContext, - state: State, - settings: Settings, - lang: Lang, - view: ReportView = FIRST_VIEW, -): Promise { - const [collections, counts, requested] = await Promise.all([ - listCollections(ctx), - countFindings(ctx), - findingsPage(ctx, view), - ]); - // A filter can name a collection deleted since the page was shown. The - // select would then have no option for it, and the host rejects the whole - // response, so the report falls back to all collections. - const known = view.collection === null || collections.some((item) => item.slug === view.collection); - if (!known) view = { ...view, collection: null, cursor: null }; - const { page, later } = known ? requested : await findingsPage(ctx, view); - const banner = [...urlPatternBanner(lang, collections, true), ...expirySuggestion(lang, settings, collections)]; - const auditNow: ButtonElement = { - type: "button", - action_id: AUDIT_NOW_ACTION, - label: t(lang, "auditNow"), - value: viewValue(view), - }; - - if (counts.total === 0) { - return { - blocks: [ - { type: "header", text: t(lang, "reportTitle") }, - ...banner, - { type: "empty", title: t(lang, "nothingTitle"), description: t(lang, "nothingText") }, - { type: "context", text: auditStatus(lang, state) }, - { type: "actions", elements: [auditNow] }, - ], - }; - } - - const labels = new Map(collections.map((item) => [item.slug, item.label || item.slug])); - const rows = page.items.map(({ data }) => data as EntryFindings); - const withLocale = new Set(rows.map((row) => row.locale ?? null)).size > 1; - const paging: ButtonElement[] = [ - ...(later ? [{ type: "button" as const, action_id: VIEW_ACTION, label: t(lang, "firstPage"), value: { ...view, cursor: null } }] : []), - ...(page.hasMore && page.cursor - ? [{ type: "button" as const, action_id: VIEW_ACTION, label: t(lang, "nextPage"), value: { ...view, cursor: page.cursor } }] - : []), - ]; - - return { - blocks: [ - { type: "header", text: t(lang, "reportTitle") }, - ...banner, - statsBlock(lang, counts, true), - { type: "context", text: auditStatus(lang, state) }, - filters(lang, view, collections), - ...(rows.length === 0 - ? [{ type: "context" as const, text: t(lang, "noMatch") }] - : rows.flatMap((row) => entryBlocks(lang, row, labels.get(row.collection) ?? row.collection, withLocale))), - { type: "actions", elements: [...paging, auditNow] }, - ], - }; -} - -/** The choice of expiry field, for a collection that has `datetime` fields at all. */ -function expirySelect(settings: Settings, lang: Lang, item: PluginCollectionInfo, collection: string): FormField[] { - const dateFields = datetimeFields(item.fields); - if (dateFields.length === 0) return []; - const suggested = detectExpiryField(dateFields); - const chosen = settings.collections[item.slug]?.expiryField; - const valid = chosen === EXPIRY_OFF || dateFields.some((field) => field.slug === chosen); - return [ - { - type: "select", - action_id: collectionField(item.slug, "expiryField"), - label: t(lang, "collectionExpiry", { collection }), - options: [ - { label: t(lang, "expiryUnset"), value: EXPIRY_UNSET }, - ...dateFields.map((field) => ({ - label: field.slug === suggested?.slug ? t(lang, "expirySuggested", { field: field.label }) : field.label, - value: field.slug, - })), - { label: t(lang, "expiryOff"), value: EXPIRY_OFF }, - ], - initial_value: valid && chosen ? chosen : EXPIRY_UNSET, - }, - ]; -} - -/** - * A pointer to the settings for collections that have a field named like an - * expiry date but no decision about it yet. Expiry is opt-in, so without this - * nobody would learn the rule exists. - */ -function expirySuggestion(lang: Lang, settings: Settings, collections: PluginCollectionInfo[]): Block[] { - const suggestions = collections.flatMap((item) => { - if (thresholdsFor(settings, item.slug).skip) return []; - const field = suggestedExpiry(settings, item.slug, datetimeFields(item.fields)); - return field ? [`${item.label || item.slug} (${field.label})`] : []; - }); - if (suggestions.length === 0) return []; - return [ - { - type: "banner", - variant: "default", - title: t(lang, "expirySuggestTitle", { names: suggestions.join(", ") }), - description: t(lang, "expirySuggestText"), - }, - { - type: "actions", - elements: [{ type: "link", label: t(lang, "openSettings"), target: { kind: "plugin-page", path: "/settings" } }], - }, - ]; -} - -function collectionFields(settings: Settings, lang: Lang, collections: PluginCollectionInfo[]): FormField[] { - return collections.flatMap((item): FormField[] => { - const own = settings.collections[item.slug] ?? {}; - const collection = item.label || item.slug; - return [ - { - type: "number_input", - action_id: collectionField(item.slug, "staleMonths"), - label: t(lang, "collectionStale", { collection, months: settings.staleMonths }), - min: 0, - max: 120, - ...(own.staleMonths !== undefined ? { initial_value: own.staleMonths } : {}), - }, - { - type: "number_input", - action_id: collectionField(item.slug, "draftMonths"), - label: t(lang, "collectionDraft", { collection, months: settings.draftMonths }), - min: 0, - max: 120, - ...(own.draftMonths !== undefined ? { initial_value: own.draftMonths } : {}), - }, - ...expirySelect(settings, lang, item, collection), - { - type: "toggle", - action_id: collectionField(item.slug, "skip"), - label: t(lang, "collectionSkip", { collection }), - initial_value: own.skip === true, - }, - ]; - }); -} - -export function buildSettingsPage( - settings: Settings, - lang: Lang, - collections: PluginCollectionInfo[] = [], -): BlockResponse { - const form: FormBlock = { - type: "form", - fields: [ - { - type: "number_input", - action_id: "staleMonths", - label: t(lang, "staleMonths"), - min: 1, - max: 120, - initial_value: settings.staleMonths, - }, - { - type: "number_input", - action_id: "draftMonths", - label: t(lang, "draftMonths"), - min: 1, - max: 120, - initial_value: settings.draftMonths, - }, - { - type: "number_input", - action_id: "descriptionMin", - label: t(lang, "descriptionMin"), - min: 0, - max: 300, - initial_value: settings.descriptionMin, - }, - { - type: "number_input", - action_id: "descriptionMax", - label: t(lang, "descriptionMax"), - min: 0, - max: 300, - initial_value: settings.descriptionMax, - }, - { - type: "number_input", - action_id: "pendingDays", - label: t(lang, "pendingDays"), - min: 1, - max: 365, - initial_value: settings.pendingDays, - }, - { - type: "toggle", - action_id: "reportMissingDescriptions", - label: t(lang, "reportMissing"), - description: t(lang, "reportMissingHelp"), - initial_value: settings.reportMissingDescriptions, - }, - { - type: "number_input", - action_id: "pageSize", - label: t(lang, "pageSize"), - min: 1, - max: ID_BATCH, - initial_value: settings.pageSize, - }, - { type: "text_input", action_id: "schedule", label: t(lang, "schedule"), initial_value: settings.schedule }, - ...collectionFields(settings, lang, collections), - ], - submit: { label: t(lang, "save"), action_id: SAVE_SETTINGS_ACTION }, - }; - return { - blocks: [ - { type: "header", text: t(lang, "settingsTitle") }, - { type: "context", text: t(lang, "settingsIntro") }, - form, - ], - }; -} diff --git a/packages/content-freshness/src/routing.ts b/packages/content-freshness/src/routing.ts deleted file mode 100644 index d957e62..0000000 --- a/packages/content-freshness/src/routing.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * The one check that is about a collection, not an entry. - * - * Without a URL pattern, EmDash builds every public URL for a collection as - * `/{collection}/{slug}`: in the sitemap, in the admin's view links, and for - * plugins asking for an entry's URL. A site whose routes differ (`/blog/…`, - * `/{slug}`, a single page at `/about`) then publishes a sitemap of 404s, - * and nothing reports it. - */ - -import type { PluginCollectionInfo } from "@eisbachcode/emdash-plugin-shared"; - -/** Routable collections that fall back to `/{collection}/{slug}`. */ -export function collectionsWithoutUrlPattern(collections: PluginCollectionInfo[]): PluginCollectionInfo[] { - return collections.filter((collection) => collection.routable && !collection.urlPattern); -} diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts deleted file mode 100644 index 490a2e4..0000000 --- a/packages/content-freshness/src/rules.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * The freshness rules — pure functions over one entry. - * - * Every rule reads fields the content API already returns (`updatedAt`, - * `status`, `scheduledAt`, `seo`, the revision ids), plus two facts about the - * entry's collection from `schema:read`: whether it keeps revisions, and which - * `datetime` field, if any, the settings chose as its expiry date. The whole - * audit runs without network access. - * - * A rule yields a `Hit`: the rule, its severity and the values its sentence - * needs. Sentences are built when a page renders, never stored, so a stored - * finding reads in whatever language the admin is shown in. - */ - -import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; - -export type Severity = "high" | "medium" | "low"; - -export const RULES = [ - "overdue-schedule", - "missing-description", - "stale", - "stale-draft", - "description-length", - "unpublished-changes", - "expired", -] as const; - -export type Rule = (typeof RULES)[number]; - -export type HitParams = Record; - -export interface Hit { - rule: Rule; - severity: Severity; - params: HitParams; -} - -/** Sort key: the lower, the more urgent. Stored so storage can order by it. */ -export const RANK: Record = { high: 0, medium: 1, low: 2 }; - -export interface Thresholds { - /** Published entries untouched for longer than this are stale. */ - staleMonths: number; - /** Drafts untouched for longer than this are forgotten. */ - draftMonths: number; - descriptionMin: number; - descriptionMax: number; - /** Off for a site whose templates always render a description of their own. */ - reportMissingDescriptions: boolean; - /** Changes saved to a published entry and left unpublished for longer than this are reported. */ - pendingDays: number; -} - -export const DEFAULT_THRESHOLDS: Thresholds = { - staleMonths: 12, - draftMonths: 6, - descriptionMin: 50, - descriptionMax: 160, - reportMissingDescriptions: true, - pendingDays: 14, -}; - -/** The `datetime` field after which a collection's entries have expired. */ -export interface ExpiryField { - slug: string; - label: string; -} - -export const DAY_MS = 24 * 60 * 60 * 1000; - -/** What a rule needs to know about an entry's collection. */ -export interface EntryContext { - /** The field the entry expires after, when the settings chose one. */ - expiry: ExpiryField | null; - /** Whether the collection keeps revisions, so a draft revision means unpublished changes. */ - revisions: boolean; -} - -export const NO_CONTEXT: EntryContext = { expiry: null, revisions: false }; - -/** - * How long a passed schedule may wait before it counts as missed. EmDash - * publishes scheduled entries from its own cron run, which on most sites - * fires every few minutes, so an entry due a minute ago is not overdue yet. - */ -export const SCHEDULE_GRACE_MS = 60 * 60 * 1000; - -/** - * Field names templates commonly render as the meta description when the - * SEO panel is empty. The page then still has a description, so a missing - * SEO description on such an entry is a low-priority finding. - */ -const FALLBACK_FIELD = /^(?:excerpt|description|summary|intro|teaser|lead|subtitle)$|_(?:excerpt|description|summary|subheadline)$/; - -function fallbackField(data: Record): string | null { - for (const [key, value] of Object.entries(data)) { - if (FALLBACK_FIELD.test(key) && typeof value === "string" && value.trim()) return key; - } - return null; -} - -export function evaluateEntry( - entry: PluginContentItem, - thresholds: Thresholds, - now: Date, - context: EntryContext = NO_CONTEXT, -): Hit[] { - const hits: Hit[] = []; - const published = entry.status === "published"; - - // In a collection with revisions, a save stages the changes in a draft - // revision and leaves `updatedAt` alone; publishing or discarding them - // clears it. Revision ids are ULIDs, so the draft revision's id is where - // the time of the last save is. Without revisions the pointer means - // nothing: a restore can set it and no save clears it. - const draftSavedAt = - context.revisions && entry.draftRevisionId && entry.draftRevisionId !== entry.liveRevisionId - ? ulidTime(entry.draftRevisionId) - : null; - const lastEdit = latest(parseDate(entry.updatedAt), draftSavedAt); - - // Changes scheduled to go live are waiting on purpose; a missed schedule - // is `overdue-schedule`'s to report. - if ( - published && - draftSavedAt && - !entry.scheduledAt && - draftSavedAt.getTime() < now.getTime() - thresholds.pendingDays * DAY_MS - ) { - hits.push({ rule: "unpublished-changes", severity: "medium", params: { since: day(draftSavedAt) } }); - } - - // EmDash stores every `datetime` value as a UTC instant, reading a value - // without a time as midnight in the site's timezone. A plugin does not - // know that timezone, so the instant is compared as it is and named with - // its time in UTC. - if (published && context.expiry) { - const value = entry.data[context.expiry.slug]; - const ends = typeof value === "string" ? parseDate(value) : null; - if (ends && ends.getTime() <= now.getTime()) { - hits.push({ rule: "expired", severity: "medium", params: { field: context.expiry.label, date: minuteUtc(ends) } }); - } - } - - // A schedule that came and went. On an unpublished entry it was meant to - // publish it; on a published one, to publish its pending changes. Either - // way someone expected a change to be live, and with autosave the publish - // button does not re-enable, so a scheduled draft can sit there for good. - const scheduledAt = parseDate(entry.scheduledAt ?? null); - if (scheduledAt && scheduledAt.getTime() < now.getTime() - SCHEDULE_GRACE_MS) { - hits.push({ - rule: "overdue-schedule", - severity: "high", - params: { date: day(scheduledAt), kind: published ? "update" : "publish" }, - }); - } - - // `seo` is undefined for a collection without SEO enabled, and that is - // not a finding — there is nothing to fill in. - if (published && entry.seo !== undefined) { - const description = entry.seo.description?.trim() ?? ""; - if (!description) { - if (thresholds.reportMissingDescriptions) { - const fallback = fallbackField(entry.data); - hits.push({ - rule: "missing-description", - severity: fallback ? "low" : "medium", - params: fallback ? { field: fallback } : {}, - }); - } - } else if ( - description.length < thresholds.descriptionMin || - description.length > thresholds.descriptionMax - ) { - hits.push({ - rule: "description-length", - severity: "low", - params: { - length: description.length, - min: thresholds.descriptionMin, - max: thresholds.descriptionMax, - }, - }); - } - } - - // A threshold of 0 switches the rule off: a collection whose entries are - // not expected to change, such as testimonials. The last edit counts - // saves staged in a draft revision, so an entry someone is working on is - // not stale; if they stop, `unpublished-changes` says so. - if (lastEdit) { - if (published && thresholds.staleMonths > 0 && lastEdit < subtractMonths(now, thresholds.staleMonths)) { - hits.push({ rule: "stale", severity: "medium", params: { since: day(lastEdit) } }); - } - if (entry.status === "draft" && thresholds.draftMonths > 0 && lastEdit < subtractMonths(now, thresholds.draftMonths)) { - hits.push({ rule: "stale-draft", severity: "low", params: { since: day(lastEdit) } }); - } - } - - return hits; -} - -/** The most urgent severity among `hits`, as a rank. */ -export function worstRank(hits: Hit[]): number { - return Math.min(...hits.map((hit) => RANK[hit.severity])); -} - -/** - * `now` minus `months`, clamping the day so 31 March minus one month is - * 28 (or 29) February rather than rolling forward into March. - */ -export function subtractMonths(from: Date, months: number): Date { - const date = from.getUTCDate(); - const shifted = new Date(from.getTime()); - shifted.setUTCDate(1); - shifted.setUTCMonth(shifted.getUTCMonth() - months); - const lastDay = new Date( - Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, 0), - ).getUTCDate(); - shifted.setUTCDate(Math.min(date, lastDay)); - return shifted; -} - -const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -/** The time a ULID was minted, from its first ten characters; null for anything that is not a ULID. */ -export function ulidTime(id: string | null | undefined): Date | null { - if (!id || !/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(id)) return null; - let ms = 0; - for (const char of id.slice(0, 10).toUpperCase()) ms = ms * 32 + CROCKFORD.indexOf(char); - return new Date(ms); -} - -/** Field names that say when an entry stops being current. */ -const EXPIRY_NAME = - /(?:^|_)(?:end|ends|until|expires?|expiry|expiration|deadline|closes?|closing|valid_to|valid_through)(?:_at|_date|_on|_time|_datetime)?$/; - -/** A collection's `datetime` fields, the only fields an expiry date can be in. */ -export function datetimeFields(fields: Array<{ slug: string; label: string; type: string }>): ExpiryField[] { - return fields - .filter((field) => field.type === "datetime") - .map((field) => ({ slug: field.slug, label: field.label || field.slug })); -} - -/** The first of `fields` whose name says when an entry runs out: a suggestion, never applied on its own. */ -export function detectExpiryField(fields: ExpiryField[]): ExpiryField | null { - return fields.find((field) => EXPIRY_NAME.test(field.slug)) ?? null; -} - -function latest(a: Date | null, b: Date | null): Date | null { - if (!a) return b; - if (!b) return a; - return a > b ? a : b; -} - -/** An instant to the minute, in UTC: `2026-01-30 23:00 UTC`. */ -function minuteUtc(date: Date): string { - return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; -} - -/** Tolerant date parse — a malformed timestamp is skipped, not thrown on. */ -export function parseDate(value: string | null | undefined): Date | null { - if (!value) return null; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -function day(date: Date): string { - return date.toISOString().slice(0, 10); -} diff --git a/packages/content-freshness/src/schedule.ts b/packages/content-freshness/src/schedule.ts deleted file mode 100644 index 09fb17d..0000000 --- a/packages/content-freshness/src/schedule.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Registering the recurring audit. - */ - -import type { PluginContext } from "emdash/plugin"; - -import type { StoredSettings } from "./settings.js"; - -/** The recurring audit. `ctx.cron.schedule()` upserts on this name. */ -export const AUDIT_TASK = "audit"; - -/** - * Register the recurring audit on the stored schedule. - * - * `plugin:activate` fires only when a plugin is enabled or installed at - * runtime. A plugin listed in `astro.config.mjs` is active from the start - * and never sees it, so the admin surfaces call this too. They skip the call - * when `scheduledAs` says the task already runs on this schedule. That - * record can outlive the task, since an uninstall deletes cron tasks but - * keeps plugin KV, so activation passes `force`. - * - * Returns whether `stored` changed and needs writing. - */ -export async function ensureScheduled(ctx: PluginContext, stored: StoredSettings, force = false): Promise { - const { schedule } = stored.settings; - if (!ctx.cron || (!force && stored.scheduledAs === schedule)) return false; - await ctx.cron.schedule(AUDIT_TASK, { schedule }); - const changed = stored.scheduledAs !== schedule; - stored.scheduledAs = schedule; - return changed; -} diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts deleted file mode 100644 index 301c393..0000000 --- a/packages/content-freshness/src/settings.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Settings, stored as one object under one KV key. Every `ctx` call is a - * subrequest in the sandbox, and a key per setting cost seven of the ten a - * cron run gets. A standard-format plugin has no declarative - * `settingsSchema` — that is native-only — so the form is hand-built in - * `report.ts`. - * - * The same object records which schedule the recurring audit was last - * registered with. Only admin requests write this key, so recording it here - * never races a cron run's write of the sweep state. - */ - -import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -import { ID_BATCH } from "./ids.js"; -import { DEFAULT_THRESHOLDS, detectExpiryField, type EntryContext, type ExpiryField, type Thresholds } from "./rules.js"; - -export const SETTINGS_KEY = "settings"; - -/** - * What one collection sets for itself. A missing threshold means the site's - * default; 0 switches the rule off for the collection. - */ -export interface CollectionOverride { - staleMonths?: number; - draftMonths?: number; - /** - * The `datetime` field entries expire after, or `"off"` for never. Unset, - * no expiry is checked and the report suggests a field whose name fits. - */ - expiryField?: string; - /** Leave the collection out of the audit altogether. */ - skip?: boolean; -} - -export interface Settings extends Thresholds { - /** Entries per cron run, at most `ID_BATCH`. */ - pageSize: number; - schedule: string; - collections: Record; -} - -export const DEFAULT_SETTINGS: Settings = { - ...DEFAULT_THRESHOLDS, - pageSize: 50, - schedule: "0 4 * * *", - collections: {}, -}; - -/** A collection slug as EmDash allows it. Anything else in stored settings is dropped. */ -const SLUG = /^[a-z][a-z0-9_]*$/; - -/** A form field for one collection's setting: `collection::`. */ -const COLLECTION_FIELD = /^collection:([a-z][a-z0-9_]*):(staleMonths|draftMonths|expiryField|skip)$/; - -/** The expiry select's value for "not chosen yet": nothing checked, a field suggested. */ -export const EXPIRY_UNSET = "unset"; -/** The expiry select's value for "entries of this collection never expire": nothing checked or suggested. */ -export const EXPIRY_OFF = "off"; - -export function collectionField(slug: string, setting: keyof CollectionOverride): string { - return `collection:${slug}:${setting}`; -} - -/** The thresholds that apply to `collection`, and whether it is audited at all. */ -export function thresholdsFor(settings: Settings, collection: string): Thresholds & { skip: boolean } { - const own = settings.collections[collection] ?? {}; - return { - staleMonths: own.staleMonths ?? settings.staleMonths, - draftMonths: own.draftMonths ?? settings.draftMonths, - descriptionMin: settings.descriptionMin, - descriptionMax: settings.descriptionMax, - reportMissingDescriptions: settings.reportMissingDescriptions, - pendingDays: settings.pendingDays, - skip: own.skip === true, - }; -} - -/** - * The field `collection`'s entries expire after, when the settings chose one - * the collection still has. Expiry is opt-in: a site that keeps past events - * online as an archive would otherwise get a finding for every one of them - * the moment the plugin is installed. `fields` are the collection's - * `datetime` fields. - */ -export function expiryFor(settings: Settings, collection: string, fields: ExpiryField[]): ExpiryField | null { - const chosen = settings.collections[collection]?.expiryField; - return fields.find((field) => field.slug === chosen) ?? null; -} - -/** What the rules need to know about `collection`, from what a sweep or a check read about it. */ -export function contextFor( - settings: Settings, - collection: string, - info: { dateFields: ExpiryField[]; revisions: boolean } | undefined, -): EntryContext { - return { - expiry: expiryFor(settings, collection, info?.dateFields ?? []), - revisions: info?.revisions ?? false, - }; -} - -/** A field to suggest for a collection whose expiry nobody has decided on yet. */ -export function suggestedExpiry(settings: Settings, collection: string, fields: ExpiryField[]): ExpiryField | null { - if (settings.collections[collection]?.expiryField !== undefined) return null; - return detectExpiryField(fields); -} - -type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax" | "pendingDays"; - -const NUMBERS: Array<[NumberKey, number, number]> = [ - ["pageSize", 1, ID_BATCH], - ["staleMonths", 1, 120], - ["draftMonths", 1, 120], - ["descriptionMin", 0, 300], - ["descriptionMax", 0, 300], - ["pendingDays", 1, 365], -]; - -export interface StoredSettings { - settings: Settings; - /** The expression the recurring audit was last scheduled with. */ - scheduledAs: string | null; -} - -export async function readStoredSettings(ctx: PluginContext): Promise { - const raw = await ctx.kv.get>(SETTINGS_KEY); - return { - settings: normalizeSettings(raw), - scheduledAs: typeof raw?.scheduledAs === "string" ? raw.scheduledAs : null, - }; -} - -export async function readSettings(ctx: PluginContext): Promise { - return (await readStoredSettings(ctx)).settings; -} - -export async function writeStoredSettings(ctx: PluginContext, stored: StoredSettings): Promise { - await ctx.kv.set(SETTINGS_KEY, { ...stored.settings, scheduledAs: stored.scheduledAs }); -} - -/** Settings from whatever is stored, every value clamped and defaulted. */ -export function normalizeSettings(stored: Record | null | undefined): Settings { - const source = stored ?? {}; - const settings = { ...DEFAULT_SETTINGS }; - for (const [key, min, max] of NUMBERS) { - settings[key] = clampNumber(source[key], min, max, DEFAULT_SETTINGS[key]); - } - if (typeof source.schedule === "string" && source.schedule.trim()) settings.schedule = source.schedule.trim(); - if (typeof source.reportMissingDescriptions === "boolean") { - settings.reportMissingDescriptions = source.reportMissingDescriptions; - } - const [min, max] = [settings.descriptionMin, settings.descriptionMax]; - settings.descriptionMin = Math.min(min, max); - settings.descriptionMax = Math.max(min, max); - settings.collections = normalizeOverrides(source.collections); - return settings; -} - -function normalizeOverrides(source: unknown): Record { - if (typeof source !== "object" || source === null) return {}; - const overrides: Record = {}; - for (const [slug, raw] of Object.entries(source as Record)) { - if (!SLUG.test(slug) || typeof raw !== "object" || raw === null) continue; - const { staleMonths, draftMonths, expiryField, skip } = raw as Record; - const own: CollectionOverride = {}; - const stale = months(staleMonths); - const draft = months(draftMonths); - if (stale !== undefined) own.staleMonths = stale; - if (draft !== undefined) own.draftMonths = draft; - if (skip === true) own.skip = true; - if (typeof expiryField === "string" && (expiryField === EXPIRY_OFF || SLUG.test(expiryField))) { - own.expiryField = expiryField; - } - if (Object.keys(own).length > 0) overrides[slug] = own; - } - return overrides; -} - -/** - * An override's months, or nothing. A value that is not a number is dropped - * rather than clamped, because 0 would switch the rule off. - */ -function months(value: unknown): number | undefined { - if (!isSet(value)) return undefined; - const n = Number(value); - return Number.isFinite(n) ? clampNumber(n, 0, 120, 0) : undefined; -} - -/** An empty number field: no override. */ -function isSet(value: unknown): boolean { - return value !== undefined && value !== null && !(typeof value === "string" && !value.trim()); -} - -/** - * `current` with the submitted form values applied. Unknown keys are ignored. - * - * `shown` names the collections whose fields the form carried. The admin - * drops an emptied number field from the submission altogether, so for those - * collections a missing month field means the admin cleared it. - */ -export function applyForm(current: Settings, values: Record, shown: string[] = []): Settings { - const merged: Record = { ...current }; - for (const [key] of NUMBERS) { - if (values[key] !== undefined) merged[key] = values[key]; - } - if (typeof values.schedule === "string" && values.schedule.trim()) merged.schedule = values.schedule; - if (typeof values.reportMissingDescriptions === "boolean") { - merged.reportMissingDescriptions = values.reportMissingDescriptions; - } - - const collections: Record> = {}; - for (const [slug, own] of Object.entries(current.collections)) collections[slug] = { ...own }; - for (const slug of shown) { - for (const setting of ["staleMonths", "draftMonths"] as const) { - if (!(collectionField(slug, setting) in values)) delete collections[slug]?.[setting]; - } - } - for (const [key, value] of Object.entries(values)) { - const match = COLLECTION_FIELD.exec(key); - if (!match) continue; - const [, slug, setting] = match as unknown as [string, string, keyof CollectionOverride]; - const own = (collections[slug] ??= {}); - if (setting === "skip") own.skip = value === true; - else if (setting === "expiryField") { - if (typeof value === "string" && value !== EXPIRY_UNSET) own.expiryField = value; - else delete own.expiryField; - } else if (isSet(value)) own[setting] = value; - else delete own[setting]; - } - merged.collections = collections; - return normalizeSettings(merged); -} diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts deleted file mode 100644 index 15be4b9..0000000 --- a/packages/content-freshness/src/state.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Where the audit stands, under one KV key: one read and at most one write - * per run, however many collections the site has. Only cron runs write it. - */ - -import type { PluginContext } from "emdash/plugin"; - -export const STATE_KEY = "state"; - -/** Raised whenever `Sweep` changes shape; a sweep of an older shape starts over. */ -export const SWEEP_VERSION = 2; - -/** What a sweep reads about a collection when it starts. */ -export interface CollectionInfo { - /** Its `datetime` fields, for the expiry rule. */ - dateFields: Array<{ slug: string; label: string }>; - /** Whether it keeps revisions, for the unpublished-changes rule. */ - revisions: boolean; -} - -export interface Sweep { - version: number; - /** Rows written by this sweep carry this as `seenIn`; older ones are removed at the end. */ - startedAt: string; - /** The collections as they were when the sweep started, walked in order. */ - collections: string[]; - index: number; - /** `content.list` cursor within `collections[index]`. */ - cursor: string | null; - phase: "audit" | "cleanup"; - /** Per collection, read with the collection list so a page needs no schema call. */ - info: Record; -} - -export interface State { - sweep: Sweep | null; - lastFinishedAt: string | null; -} - -const EMPTY: State = { sweep: null, lastFinishedAt: null }; - -export async function readState(ctx: PluginContext): Promise { - const stored = await ctx.kv.get>(STATE_KEY); - return { ...EMPTY, ...stored }; -} - -export async function writeState(ctx: PluginContext, state: State): Promise { - await ctx.kv.set(STATE_KEY, state); -} diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts deleted file mode 100644 index ba72794..0000000 --- a/packages/content-freshness/src/sweep.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * The audit: a sweep over every collection, one page per cron run. - * - * A sandboxed plugin gets ten subrequests per invocation and every `ctx` - * call is one, so a run does one bounded slice and hands over to a - * follow-up. An audit run costs two KV reads (settings, state), one - * `content.list`, one `dismissals.getMany`, at most one `putMany` and one - * `deleteMany`, one KV write and one schedule, plus the collection list when - * a sweep starts: nine at most. A cleanup run costs six, its log line - * included. `tests/budget.test.ts` counts every path; run it after adding a - * `ctx` call. - * - * When the last collection is done, the sweep removes every row it did not - * write or confirm: entries that were trashed or deleted, and collections - * that are gone. - */ - -import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -import type { EntryDismissals } from "./dismissals.js"; -import { evaluateEntries, store } from "./findings.js"; -import { findingId, ID_BATCH } from "./ids.js"; -import { datetimeFields } from "./rules.js"; -import { contextFor, readSettings, thresholdsFor, type Settings } from "./settings.js"; -import { AUDIT_TASK } from "./schedule.js"; -import { readState, SWEEP_VERSION, writeState, type CollectionInfo, type State, type Sweep } from "./state.js"; - -/** The one-shot the "Audit now" button schedules. See `plugin.ts` for why it has its own name. */ -export const AUDIT_NOW_TASK = "audit-now"; - -/** 500 runs is 50,000 entries at the largest page size. */ -export const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSteps: 500 }; - -/** A sweep that has not finished in this long is dropped and started over. */ -const ABANDON_AFTER_MS = 7 * 24 * 60 * 60 * 1000; - - -/** - * Handle a cron event. A start while a sweep is under way continues that - * sweep instead of resetting it, so a site whose sweep outlasts a day - * still finishes, and a follow-up chain that got lost resumes on the next - * scheduled run. - */ -export async function runAudit(ctx: PluginContext, event: ChainEvent): Promise { - const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; - if (!starts && !isFollowUp(CHAIN, event.name)) return; - // Without `schema:read` the collection list comes back empty, and a sweep - // over no collections would go straight to cleaning up every row. - if (!ctx.content || !ctx.schema) return; - - const [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); - const now = new Date(); - - if (state.sweep && state.sweep.version !== SWEEP_VERSION) { - // Written by an earlier version of the plugin: start over, whatever - // the event, rather than audit with information the sweep lacks. - state.sweep = await newSweep(ctx, settings, now); - } else if (!state.sweep || abandoned(state.sweep, now)) { - // A follow-up of a sweep that already finished has nothing to do. - if (!starts) return; - state.sweep = await newSweep(ctx, settings, now); - } - - const more = await step(ctx, settings, state, now); - await writeState(ctx, state); - if (more && !(await scheduleFollowUp(ctx, CHAIN, event))) { - ctx.log.warn(`Audit paused after ${CHAIN.maxSteps} runs; it continues at the next scheduled run`); - } -} - -/** - * A sweep over every collection not left out. With none left it still runs - * its cleanup, which removes the rows of collections that were left out. - */ -async function newSweep(ctx: PluginContext, settings: Settings, now: Date): Promise { - const audited = (await listCollections(ctx)).filter((collection) => !thresholdsFor(settings, collection.slug).skip); - const info: Record = {}; - for (const collection of audited) { - info[collection.slug] = { - dateFields: datetimeFields(collection.fields), - revisions: collection.supports.includes("revisions"), - }; - } - return { - version: SWEEP_VERSION, - startedAt: now.toISOString(), - collections: audited.map((collection) => collection.slug), - index: 0, - cursor: null, - phase: "audit", - info, - }; -} - -function abandoned(sweep: Sweep, now: Date): boolean { - return now.getTime() - Date.parse(sweep.startedAt) > ABANDON_AFTER_MS; -} - -/** One slice of work. Returns whether the sweep needs another run. */ -async function step(ctx: PluginContext, settings: Settings, state: State, now: Date): Promise { - const sweep = state.sweep; - if (!sweep) return false; - - if (sweep.phase === "audit") { - const collection = sweep.collections[sweep.index]; - if (collection !== undefined) { - await auditPage(ctx, settings, sweep, collection, now); - return true; - } - sweep.phase = "cleanup"; - } - - const stale = await ctx.storage.findings.query({ - where: { seenIn: { lt: sweep.startedAt } }, - limit: ID_BATCH, - }); - if (stale.items.length > 0) await ctx.storage.findings.deleteMany(stale.items.map((item) => item.id)); - if (stale.hasMore) return true; - - state.sweep = null; - state.lastFinishedAt = now.toISOString(); - ctx.log.info("Freshness audit complete"); - return false; -} - -async function auditPage( - ctx: PluginContext, - settings: Settings, - sweep: Sweep, - collection: string, - now: Date, -): Promise { - const content = ctx.content; - if (!content) return; - const thresholds = thresholdsFor(settings, collection); - // Left out since the sweep started: its rows go with the cleanup. - if (thresholds.skip) { - sweep.index += 1; - sweep.cursor = null; - return; - } - let page; - try { - page = await content.list(collection, { - limit: settings.pageSize, - ...(sweep.cursor ? { cursor: sweep.cursor } : {}), - }); - } catch (error) { - // Only a collection deleted since the sweep started is skipped; its - // rows then fail the cleanup's `seenIn` check with the other - // leftovers. Any other failure propagates before the state is - // written, so no finding is removed for a collection that was never - // audited, and the next scheduled run retries this page. - if (await collectionExists(ctx, collection)) throw error; - ctx.log.warn(`Skipping collection ${collection}, which no longer exists`); - sweep.index += 1; - sweep.cursor = null; - return; - } - - const ids = page.items.map((entry) => findingId(collection, entry.id)); - const dismissals = ids.length > 0 ? await ctx.storage.dismissals.getMany(ids) : new Map(); - await store( - ctx, - evaluateEntries( - collection, - page.items, - thresholds, - now, - sweep.startedAt, - dismissals as Map, - contextFor(settings, collection, sweep.info[collection]), - ), - ); - - if (page.cursor) { - sweep.cursor = page.cursor; - } else { - sweep.index += 1; - sweep.cursor = null; - } -} - -async function collectionExists(ctx: PluginContext, slug: string): Promise { - if (!ctx.schema) return true; - return (await ctx.schema.getCollection(slug)) !== null; -} diff --git a/packages/content-freshness/tests/audit.test.ts b/packages/content-freshness/tests/audit.test.ts deleted file mode 100644 index 58f41da..0000000 --- a/packages/content-freshness/tests/audit.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { STATE_KEY, SWEEP_VERSION, type State } from "../src/state.js"; -import { failNextCall } from "./bridge-calls.js"; -import { finding, newHost, runSweep, undescribed } from "./host.js"; - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -describe("a sweep", () => { - it("stores one row for an entry with findings, keyed by collection and entry", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "no-description"); - await runSweep(host); - - expect(await finding(host, "posts", entry.id)).toMatchObject({ - slug: "no-description", - rank: 1, - hits: [{ rule: "missing-description", severity: "medium" }], - }); - }); - - it("removes the rows of entries it no longer finds", async () => { - host = await newHost(); - const kept = await undescribed(host, "posts", "kept"); - // A row left from an earlier sweep, for an entry deleted since. - await host.fixtures.plugin.storage("findings", "posts:01GONE", { - collection: "posts", - entryId: "01GONE", - slug: "gone", - locale: "en", - rank: 1, - hits: [{ rule: "missing-description", severity: "medium", params: {} }], - entryUpdatedAt: "2026-01-01T00:00:00.000Z", - seenIn: "2026-01-01T00:00:00.000Z", - }); - - await runSweep(host); - - expect(await finding(host, "posts", "01GONE")).toBeNull(); - expect(await finding(host, "posts", kept.id)).not.toBeNull(); - }); - - it("does not keep a trashed entry's row", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "trashed"); - await host.actions.content.trash("posts", entry.id); - // The trash action's own hook runs deferred; let it finish, then put - // the row back as an earlier sweep would have left it. - await vi.waitFor(async () => expect(await finding(host!, "posts", entry.id)).toBeNull()); - await host.fixtures.plugin.storage("findings", `posts:${entry.id}`, { - collection: "posts", - entryId: entry.id, - slug: "trashed", - locale: "en", - rank: 1, - hits: [{ rule: "missing-description", severity: "medium", params: {} }], - entryUpdatedAt: entry.updatedAt, - seenIn: "2026-01-01T00:00:00.000Z", - }); - - await runSweep(host); - - expect(await finding(host, "posts", entry.id)).toBeNull(); - }); -}); - -describe("a sweep that cannot list a collection", () => { - it("keeps that collection's findings when the collection still exists", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "kept-on-error"); - await runSweep(host); - - failNextCall("contentList"); - host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); - await host.transport.invokeHook("cron", { name: "audit-now" }).catch(() => undefined); - while ((await host.scheduled.run()).processed > 0); - - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - }); - - it("skips a collection deleted since the sweep started, and its rows go", async () => { - host = await newHost(); - await host.fixtures.plugin.storage("findings", "gone:01GONE", { - collection: "gone", - entryId: "01GONE", - slug: "gone", - locale: "en", - rank: 1, - hits: [{ rule: "missing-description", severity: "medium", params: {} }], - entryUpdatedAt: "2026-01-01T00:00:00.000Z", - seenIn: "2026-01-01T00:00:00.000Z", - }); - const sweep = { - version: SWEEP_VERSION, - startedAt: new Date().toISOString(), - collections: ["gone", "posts"], - index: 0, - cursor: null, - phase: "audit", - info: {}, - }; - await host.fixtures.plugin.kv(STATE_KEY, { sweep, lastFinishedAt: null }); - - host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); - await host.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } }); - while ((await host.scheduled.run()).processed > 0); - - expect(await finding(host, "gone", "01GONE")).toBeNull(); - expect(await host.inspect.kv.get(STATE_KEY)).toMatchObject({ sweep: null }); - }); -}); - -describe("a sweep started by an earlier version of the plugin", () => { - it("starts over instead of auditing with information it lacks", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "after-upgrade"); - const old = { startedAt: new Date().toISOString(), collections: ["posts"], index: 0, cursor: null, phase: "audit" }; - await host.fixtures.plugin.kv(STATE_KEY, { sweep: old, lastFinishedAt: null }); - - host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); - await host.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } }); - const state = await host.inspect.kv.get(STATE_KEY); - - expect(state?.sweep?.version ?? SWEEP_VERSION).toBe(SWEEP_VERSION); - while ((await host.scheduled.run()).processed > 0); - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - }); -}); - -describe("content hooks", () => { - it("drop an entry's row when it is deleted", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "to-delete"); - await runSweep(host); - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - - await host.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: true }); - - expect(await finding(host, "posts", entry.id)).toBeNull(); - }); - - it("re-check an entry when its state changes", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "to-unpublish"); - await runSweep(host); - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - - // Through EmDash's own pipeline: SEO is not judged on a draft, so the - // finding goes away once the unpublish hook has re-checked the entry. - await host.actions.content.unpublish("posts", entry.id); - await vi.waitFor(async () => expect(await finding(host!, "posts", entry.id)).toBeNull()); - }); - - it("record a finding as soon as an entry is published", async () => { - host = await newHost(); - const entry = await host.fixtures.content("posts", { slug: "fresh-draft", data: {} }); - expect(await finding(host, "posts", entry.id)).toBeNull(); - - await host.actions.content.publish("posts", entry.id); - await vi.waitFor(async () => - expect(await finding(host!, "posts", entry.id)).toMatchObject({ hits: [{ rule: "missing-description" }] }), - ); - }); -}); diff --git a/packages/content-freshness/tests/bridge-calls.ts b/packages/content-freshness/tests/bridge-calls.ts deleted file mode 100644 index 850b1e7..0000000 --- a/packages/content-freshness/tests/bridge-calls.ts +++ /dev/null @@ -1,55 +0,0 @@ -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/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts deleted file mode 100644 index 720f5cd..0000000 --- a/packages/content-freshness/tests/budget.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION, VIEW_ACTION } from "../src/report.js"; -import { SETTINGS_KEY } from "../src/settings.js"; -import { STATE_KEY, SWEEP_VERSION, type State } from "../src/state.js"; -import { bridgeCalls } from "./bridge-calls.js"; -import { finding, newHost, undescribed } 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, - * `ctx.log` included, is one; the eleventh throws "Too many subrequests" on - * Cloudflare. The harness does not enforce the limit, so each test counts - * the calls and also checks the invocation did its work, so a fixture too - * small to reach the worst case fails instead of passing quietly. - */ - -const LIMIT = 10; - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -async function state(runtime: PluginRuntimeTestHost) { - return runtime.inspect.kv.get(STATE_KEY); -} - -/** - * Four entries, two per page, created flagged and clean in turn: whichever - * way `content.list` orders them, every page holds one entry with a finding - * and one without, so every audit run both writes and clears. - */ -async function twoPageSite() { - const runtime = await newHost(); - await runtime.fixtures.plugin.kv(SETTINGS_KEY, { pageSize: 2 }); - const flagged = []; - const clean = []; - for (const n of [1, 2]) { - flagged.push(await undescribed(runtime, "posts", `flagged-${n}`)); - clean.push(await runtime.fixtures.content("posts", { slug: `clean-${n}`, data: {}, status: "draft" })); - } - return { runtime, flagged, clean }; -} - -function staleRow(entryId: string) { - return { - collection: "posts", - entryId, - slug: entryId, - locale: "en", - rank: 2, - hits: [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], - entryUpdatedAt: "2025-01-01T00:00:00.000Z", - seenIn: "2026-01-01T00:00:00.000Z", - }; -} - -describe("cron runs", () => { - it("the run that starts a sweep", async () => { - const { runtime } = await twoPageSite(); - host = runtime; - - const calls = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit" })); - - expect(calls).toEqual(expect.arrayContaining(["schemaListCollections", "storagePutMany", "storageDeleteMany"])); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - }); - - it("a follow-up that writes and clears", async () => { - const { runtime, flagged, clean } = await twoPageSite(); - host = runtime; - // Leftover rows for the clean entries, which the audit must clear. - for (const entry of clean) { - await runtime.fixtures.plugin.storage("findings", `posts:${entry.id}`, staleRow(entry.id)); - } - await runtime.transport.invokeHook("cron", { name: "audit" }); - - const calls = await bridgeCalls(() => - runtime.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } }), - ); - - expect(calls).toEqual(expect.arrayContaining(["contentList", "storagePutMany", "storageDeleteMany"])); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - // Both pages done: every clean entry cleared, every flagged one stored. - for (const entry of clean) expect(await finding(runtime, "posts", entry.id)).toBeNull(); - for (const entry of flagged) expect(await finding(runtime, "posts", entry.id)).not.toBeNull(); - }); - - it("a cleanup run with more stale rows than one batch, and the run that finishes", async () => { - host = await newHost(); - const runtime = host; - for (let i = 0; i < 101; i++) { - await runtime.fixtures.plugin.storage("findings", `posts:OLD${i}`, staleRow(`OLD${i}`)); - } - const sweep = { - version: SWEEP_VERSION, - startedAt: new Date().toISOString(), - collections: ["posts"], - index: 1, - cursor: null, - phase: "cleanup", - info: {}, - }; - await runtime.fixtures.plugin.kv(STATE_KEY, { sweep, lastFinishedAt: null, scheduledAs: null }); - - const batch = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } })); - expect(batch).toContain("storageDeleteMany"); - expect((await state(runtime))?.sweep).not.toBeNull(); - expect(batch.length).toBeLessThanOrEqual(LIMIT); - - const last = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit-next-b", data: { step: 2 } })); - expect((await state(runtime))?.sweep).toBeNull(); - expect(last.length).toBeLessThanOrEqual(LIMIT); - }); -}); - -describe("content hooks", () => { - it("a state change that writes a finding", async () => { - host = await newHost(); - const runtime = host; - const entry = await undescribed(runtime, "posts", "published-now"); - - const calls = await bridgeCalls(() => - runtime.transport.invokeHook("content:afterPublish", { content: { id: entry.id }, collection: "posts" }), - ); - - expect(await finding(runtime, "posts", entry.id)).not.toBeNull(); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - }); -}); - -describe("the Freshness panel", () => { - it("opening it, and setting a finding aside", async () => { - host = await newHost(); - const runtime = host; - const entry = await undescribed(runtime, "posts", "panel-budget"); - - const load = await bridgeCalls(() => runtime.admin.loadEditorPanel("freshness", "posts", entry.id)); - expect(load.length).toBeLessThanOrEqual(LIMIT); - - const act = await bridgeCalls(() => - runtime.admin.actEditorPanel("freshness", "posts", entry.id, "set_aside", { value: { rule: "missing-description" } }), - ); - expect(act).toContain("storagePut"); - expect(act.length).toBeLessThanOrEqual(LIMIT); - }); -}); - -describe("admin requests", () => { - async function siteWithFindings() { - const runtime = await newHost(); - // A routable collection without a URL pattern, so the banner's lookup runs too. - await runtime.fixtures.collection({ slug: "pages", label: "Pages", routable: true }); - await undescribed(runtime, "posts", "flagged"); - await runtime.transport.invokeHook("cron", { name: "audit" }); - return runtime; - } - - it("an empty dashboard spends nothing on counting the viewer's entries", async () => { - host = await newHost(); - const runtime = host; - const calls = await bridgeCalls(() => runtime.admin.loadWidget("summary")); - expect(calls.filter((call) => call === "storageCount")).toHaveLength(3); - }); - - it("the first dashboard view, which also schedules the audit", async () => { - host = await siteWithFindings(); - const runtime = host; - const calls = await bridgeCalls(() => runtime.admin.loadWidget("summary")); - expect(calls).toContain("cronSchedule"); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - }); - - it("the first report view, which also schedules the audit", async () => { - host = await siteWithFindings(); - const runtime = host; - const calls = await bridgeCalls(() => runtime.admin.loadPage("/report")); - expect(calls).toContain("cronSchedule"); - expect(calls).toContain("storageQuery"); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - }); - - it("paging, Audit now and saving the settings", async () => { - host = await siteWithFindings(); - const runtime = host; - for (const invocation of [ - () => runtime.admin.act("/report", VIEW_ACTION, { value: { collection: "posts", rank: 1, cursor: "x" } }), - () => runtime.admin.act("/report", AUDIT_NOW_ACTION), - () => runtime.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "15 3 * * *", staleMonths: 6 }), - ]) { - const calls = await bridgeCalls(invocation); - expect(calls.length).toBeLessThanOrEqual(LIMIT); - } - }); -}); diff --git a/packages/content-freshness/tests/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts deleted file mode 100644 index be36909..0000000 --- a/packages/content-freshness/tests/cron-names.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * The "Audit now" button schedules a one-shot task under its own name, - * because `ctx.cron.schedule()` upserts on (plugin, task name): scheduling - * the recurring name with a one-shot timestamp would replace the nightly - * audit instead of running once. So the name has to differ, and the cron - * handler has to answer to both. - */ - -/** Minimal context: settings and state come from KV, and a site without collections sweeps straight to its cleanup. */ -function fakeContext() { - const warns: string[] = []; - const reads: string[] = []; - const ctx = { - kv: { - async get(key: string) { - reads.push(key); - return null; - }, - async set() {}, - }, - content: {}, - schema: { listCollections: async () => [] }, - storage: { findings: { query: async () => ({ items: [], hasMore: false }) } }, - log: { - debug() {}, - info() {}, - warn(message: string) { - warns.push(message); - }, - error() {}, - }, - } as unknown as PluginContext; - return { ctx, warns, reads }; -} - -function cronHandler() { - const entry = plugin.hooks?.cron; - if (!entry) throw new Error("plugin registers no cron hook"); - return typeof entry === "function" ? entry : entry.handler; -} - -async function run(name: string) { - const { ctx, warns, reads } = fakeContext(); - await cronHandler()({ name, scheduledAt: "2026-09-20T03:00:00.000Z" }, ctx); - return { warns, reads }; -} - -describe("cron task names", () => { - it("runs for the recurring audit", async () => { - const { reads } = await run("audit"); - expect(reads).not.toHaveLength(0); - }); - - it("runs for the one-shot the Audit now button schedules", async () => { - // Without this the button schedules a task that fires and is - // discarded, so the button appears to do nothing at all. - const { reads } = await run("audit-now"); - expect(reads).not.toHaveLength(0); - }); - - it("reaches the same code path for both names", async () => { - const recurring = await run("audit"); - const oneShot = await run("audit-now"); - expect(oneShot.warns).toEqual(recurring.warns); - expect(oneShot.reads).toEqual(recurring.reads); - }); - - it("ignores a task belonging to something else", async () => { - const { reads, warns } = await run("some-other-plugins-task"); - expect(reads).toHaveLength(0); - expect(warns).toHaveLength(0); - }); -}); - -describe("a context without schema:read", () => { - it("starts no sweep, which would clean up every row", async () => { - const queried: string[] = []; - const ctx = { - kv: { get: async () => null, set: async () => {} }, - content: {}, - storage: { findings: { query: async () => (queried.push("query"), { items: [], hasMore: false }) } }, - log: { debug() {}, info() {}, warn() {}, error() {} }, - } as unknown as PluginContext; - - await cronHandler()({ name: "audit", scheduledAt: "2026-09-20T03:00:00.000Z" }, ctx); - - expect(queried).toEqual([]); - }); -}); diff --git a/packages/content-freshness/tests/dismissals.test.ts b/packages/content-freshness/tests/dismissals.test.ts deleted file mode 100644 index 1db27f2..0000000 --- a/packages/content-freshness/tests/dismissals.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { dismissalFor, partition, type EntryDismissals } from "../src/dismissals.js"; -import { DEFAULT_THRESHOLDS, type Hit } from "../src/rules.js"; - -const NOW = new Date("2026-09-25T12:00:00.000Z"); -const stale: Hit = { rule: "stale", severity: "medium", params: { since: "2025-01-01" } }; -const missing: Hit = { rule: "missing-description", severity: "medium", params: {} }; -const overdue: Hit = { rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }; - -function dismissed(rules: EntryDismissals["rules"]): EntryDismissals { - return { collection: "pages", entryId: "01A", rules }; -} - -describe("setting a finding aside", () => { - it("holds a reviewed stale entry back for as long as the stale threshold", () => { - const dismissal = dismissalFor("stale", { ...DEFAULT_THRESHOLDS, staleMonths: 24 }, NOW, "u1"); - expect(dismissal?.until?.slice(0, 10)).toBe("2028-09-25"); - }); - - it("ignores a description finding for good, and never a missed schedule", () => { - expect(dismissalFor("missing-description", DEFAULT_THRESHOLDS, NOW, null)?.until).toBeNull(); - expect(dismissalFor("overdue-schedule", DEFAULT_THRESHOLDS, NOW, null)).toBeNull(); - }); - - it("lets a review run out", () => { - const aside = dismissed({ stale: { until: "2026-09-01T00:00:00.000Z", by: null, at: "2025-09-01T00:00:00.000Z" } }); - expect(partition([stale], aside, NOW).active).toEqual([stale]); - }); - - it("keeps what is set aside out of the active findings", () => { - const aside = dismissed({ - stale: { until: "2027-01-01T00:00:00.000Z", by: null, at: NOW.toISOString() }, - "missing-description": { until: null, by: null, at: NOW.toISOString() }, - }); - expect(partition([stale, missing, overdue], aside, NOW)).toEqual({ active: [overdue], setAside: [stale, missing] }); - }); -}); diff --git a/packages/content-freshness/tests/expiry.test.ts b/packages/content-freshness/tests/expiry.test.ts deleted file mode 100644 index a7bfcb5..0000000 --- a/packages/content-freshness/tests/expiry.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { validateBlocks } from "@emdash-cms/blocks/server"; -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { PANEL_ID, SET_ASIDE_ACTION } from "../src/panel.js"; -import { SAVE_SETTINGS_ACTION } from "../src/report.js"; -import { finding, runSweep } from "./host.js"; - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -/** A collection of offers with a `valid_until` date, and one offer that ran out. */ -async function offers() { - const runtime = await createPluginRuntimeTestHost(); - await runtime.fixtures.collection({ - slug: "offers", - label: "Offers", - routable: true, - urlPattern: "/offers/{slug}", - fields: [{ slug: "valid_until", label: "Valid until", type: "datetime" }], - }); - // How EmDash stores 31 January for a site in Berlin. - const entry = await runtime.fixtures.content("offers", { - slug: "summer-sale", - data: { valid_until: "2026-01-30T23:00:00.000Z" }, - status: "published", - }); - return { runtime, entry }; -} - -const chooseField = (runtime: PluginRuntimeTestHost, value: string) => - runtime.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:offers:expiryField": value }); - -describe("expiry", () => { - it("is suggested in the report, and checks nothing until the field is chosen", async () => { - const { runtime, entry } = await offers(); - host = runtime; - await runSweep(runtime); - - expect(await finding(runtime, "offers", entry.id)).toBeNull(); - const report = await runtime.admin.loadPage("/report"); - expect(validateBlocks(report.blocks).valid).toBe(true); - expect(JSON.stringify(report.blocks)).toContain("Expiry dates not checked: Offers (Valid until)"); - const settings = await runtime.admin.loadPage("/settings"); - expect(validateBlocks(settings.blocks).valid).toBe(true); - expect(JSON.stringify(settings.blocks)).toContain("Valid until (suggested)"); - }); - - it("reports an expired entry once the field is chosen, naming the instant in UTC", async () => { - const { runtime, entry } = await offers(); - host = runtime; - await chooseField(runtime, "valid_until"); - await runSweep(runtime); - - const row = await finding(runtime, "offers", entry.id); - expect(row?.hits).toContainEqual( - expect.objectContaining({ rule: "expired", params: { field: "Valid until", date: "2026-01-30 23:00 UTC" } }), - ); - expect(JSON.stringify((await runtime.admin.loadPage("/report")).blocks)).not.toContain("Expiry dates not checked"); - }); - - it("comes back after an ignore once the date moved and passed again", async () => { - const { runtime, entry } = await offers(); - host = runtime; - await chooseField(runtime, "valid_until"); - await runtime.admin.actEditorPanel(PANEL_ID, "offers", entry.id, SET_ASIDE_ACTION, { value: { rule: "expired" } }); - expect(await finding(runtime, "offers", entry.id)).toBeNull(); - - // Next season's offer, which has run out as well. The collection keeps - // revisions, so the new date is live once it is published. - await runtime.actions.content.update("offers", entry.id, { data: { valid_until: "2026-08-31T22:00:00.000Z" } }); - await runtime.actions.content.publish("offers", entry.id); - const panel = await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); - - expect(JSON.stringify(panel.blocks)).toContain("2026-08-31 22:00 UTC"); - expect(await finding(runtime, "offers", entry.id)).not.toBeNull(); - }); - - it("is neither checked nor suggested once a collection never expires", async () => { - const { runtime } = await offers(); - host = runtime; - await chooseField(runtime, "off"); - expect(JSON.stringify((await runtime.admin.loadPage("/report")).blocks)).not.toContain("Expiry dates not checked"); - }); -}); diff --git a/packages/content-freshness/tests/full-audit.test.ts b/packages/content-freshness/tests/full-audit.test.ts deleted file mode 100644 index baf9e2a..0000000 --- a/packages/content-freshness/tests/full-audit.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { STATE_KEY, type State } from "../src/state.js"; - -/** - * One scheduled run audits one page of one collection. A nightly schedule - * that ran one page per night would need a night per collection, so a run - * that made progress hands over to a follow-up run. Through the real - * runtime and cron executor, because the executor deletes a finished - * one-shot task by id and would throw away a follow-up scheduled under the - * running task's own name. - */ - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -describe("a scheduled audit", () => { - it("gets through every collection on the night it starts", async () => { - host = await createPluginRuntimeTestHost(); - for (const slug of ["posts", "pages", "projects"]) { - await host.fixtures.collection({ slug, label: slug }); - await host.fixtures.content(slug, { slug: `${slug}-1`, data: {} }); - } - - // Follow-ups are scheduled from the wall clock (`Date.now()`), not the - // host's clock, so the night must lie ahead of the real time or they - // never fall due. Tomorrow's night always does. - const night = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - host.scheduled.setTime(`${night}T03:59:00.000Z`); - await host.actions.plugin.activate(); - host.scheduled.setTime(`${night}T04:00:05.000Z`); - - let runs = 0; - for (; runs < 20; runs++) { - if ((await host.scheduled.run()).processed === 0) break; - } - - expect(runs).toBeLessThan(20); - expect(await host.inspect.kv.get(STATE_KEY)).toMatchObject({ sweep: null, lastFinishedAt: expect.any(String) }); - }); -}); diff --git a/packages/content-freshness/tests/host.ts b/packages/content-freshness/tests/host.ts deleted file mode 100644 index 334faa3..0000000 --- a/packages/content-freshness/tests/host.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; - -import type { EntryFindings } from "../src/findings.js"; - -/** - * Hosts for tests that run the plugin sandboxed in workerd. - * - * Two properties of this harness shape the tests: - * - * - `actions.content.*` fire the plugin's hooks deferred through `after()`, - * and nothing awaits them. Hooks are therefore driven with - * `transport.invokeHook()`, and the one test of the real pipeline waits - * for its effect. - * - Plugin storage outlives `dispose()` within a test file, so assertions - * name the rows they are about instead of counting the collection. - */ - -/** A host with one SEO-enabled collection, so a published entry without a description has a finding. */ -export async function newHost(slug = "posts"): Promise { - const host = await createPluginRuntimeTestHost(); - await host.fixtures.collection({ slug, label: slug, hasSeo: true, routable: true, urlPattern: `/${slug}/{slug}` }); - return host; -} - -/** A published entry with no SEO description: one medium finding. */ -export async function undescribed(host: PluginRuntimeTestHost, collection: string, slug: string) { - return host.fixtures.content(collection, { slug, data: {}, status: "published" }); -} - -/** - * Start an audit and run the real cron executor until nothing is due. - * - * Follow-ups are scheduled from the wall clock, so the host's scheduler - * clock is moved a day past it; every one-shot is then due at once. - */ -export async function runSweep(host: PluginRuntimeTestHost): Promise { - host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); - await host.transport.invokeHook("cron", { name: "audit-now" }); - for (let runs = 0; runs < 50; runs++) { - if ((await host.scheduled.run()).processed === 0) return; - } - throw new Error("the sweep did not finish in 50 runs"); -} - -export async function finding(host: PluginRuntimeTestHost, collection: string, entryId: string) { - return host.inspect.storage.get("findings", `${collection}:${entryId}`); -} diff --git a/packages/content-freshness/tests/i18n.test.ts b/packages/content-freshness/tests/i18n.test.ts deleted file mode 100644 index 6af82a3..0000000 --- a/packages/content-freshness/tests/i18n.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { langOf } from "../src/i18n.js"; -import { PANEL_ID } from "../src/panel.js"; -import { newHost, runSweep, undescribed } from "./host.js"; - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -describe("the admin language", () => { - it("is German for every German locale and English otherwise", () => { - expect(["de", "de-AT", "de_CH"].map(langOf)).toEqual(["de", "de", "de"]); - expect(["en", "fr", undefined].map(langOf)).toEqual(["en", "en", "en"]); - }); - - it("carries into the report, the widget and the panel", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "auf-deutsch"); - await runSweep(host); - - const report = JSON.stringify((await host.admin.loadPage("/report", { locale: "de" })).blocks); - expect(report).toContain("Aktualität der Inhalte"); - expect(report).toContain("Veröffentlicht ohne SEO-Beschreibung."); - - const widget = JSON.stringify((await host.admin.loadWidget("summary", { locale: "de" })).blocks); - expect(widget).toContain("Bericht öffnen"); - - const panel = JSON.stringify((await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id, { locale: "de" })).blocks); - expect(panel).toContain("Ignorieren"); - }); -}); diff --git a/packages/content-freshness/tests/panel.test.ts b/packages/content-freshness/tests/panel.test.ts deleted file mode 100644 index 8d271bb..0000000 --- a/packages/content-freshness/tests/panel.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Block, ButtonElement, SectionBlock } from "@emdash-cms/blocks"; -import { validateBlocks } from "@emdash-cms/blocks/server"; -import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { PANEL_ID, renderPanel, SET_ASIDE_ACTION, UNDO_ACTION } from "../src/panel.js"; -import { bridgeCalls } from "./bridge-calls.js"; -import { finding, newHost, runSweep, undescribed } from "./host.js"; - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); -const button = (section: SectionBlock | undefined) => section?.accessory as ButtonElement | undefined; - -describe("the Freshness panel", () => { - it("shows the entry's findings and records them", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "panel-shows"); - - const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); - - expect(validateBlocks(response.blocks).valid).toBe(true); - const [section] = sections(response.blocks); - expect(section?.text).toContain("SEO description"); - expect(button(section)).toMatchObject({ action_id: SET_ASIDE_ACTION, label: "Ignore" }); - // Opening the panel is a check: the report has the finding before any sweep. - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - }); - - it("sets a finding aside so that neither the report nor a sweep brings it back, and undoes it", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "panel-ignores"); - await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); - - const ignored = await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { - value: { rule: "missing-description" }, - }); - expect(validateBlocks(ignored.blocks).valid).toBe(true); - expect(button(sections(ignored.blocks)[0])).toMatchObject({ action_id: UNDO_ACTION }); - expect(await finding(host, "posts", entry.id)).toBeNull(); - - await runSweep(host); - expect(await finding(host, "posts", entry.id)).toBeNull(); - - await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, UNDO_ACTION, { value: { rule: "missing-description" } }); - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - }); - - it("opens for an author on their own entry", async () => { - // The report needs plugins:read (editor). The panel is where an author - // sees and sets aside the findings on their own entries. - host = await newHost(); - const author = await host.fixtures.user({ email: "author@example.test", role: "author" }); - const entry = await host.fixtures.content("posts", { - slug: "authors-own", - data: {}, - status: "published", - authorId: author.id, - }); - - const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id, { user: author }); - - expect(sections(response.blocks)[0]?.text).toContain("SEO description"); - }); -}); - -describe("the panel on reload", () => { - it("writes nothing when the entry's findings have not changed", async () => { - // EmDash reloads an open panel after every save, autosaves included. - host = await newHost(); - const runtime = host; - const entry = await undescribed(runtime, "posts", "reloaded"); - await runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); - - const calls = await bridgeCalls(() => runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id)); - - expect(calls.filter((call) => /Put|Delete/.test(call))).toEqual([]); - }); - - it("drops a review that has run out", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "expired-review"); - const id = `posts:${entry.id}`; - await host.fixtures.plugin.storage("dismissals", id, { - collection: "posts", - entryId: entry.id, - rules: { stale: { until: "2026-01-01T00:00:00.000Z", by: null, at: "2025-01-01T00:00:00.000Z" } }, - }); - - await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); - - expect(await host.inspect.storage.get("dismissals", id)).toBeNull(); - }); -}); - -describe("what is set aside", () => { - async function ignoredEntry(slug: string) { - const runtime = await newHost(); - const entry = await undescribed(runtime, "posts", slug); - await runtime.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { - value: { rule: "missing-description" }, - }); - return { runtime, id: `posts:${entry.id}`, entry }; - } - - it("stays while the entry is only in the trash", async () => { - const { runtime, id, entry } = await ignoredEntry("trashed-keeps"); - host = runtime; - await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: false }); - expect(await runtime.inspect.storage.get("dismissals", id)).not.toBeNull(); - }); - - it("goes when the entry is deleted for good", async () => { - const { runtime, id, entry } = await ignoredEntry("deleted-drops"); - host = runtime; - await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: true }); - expect(await runtime.inspect.storage.get("dismissals", id)).toBeNull(); - }); -}); - -describe("the panel's controls", () => { - it("offer no way to set aside a missed schedule", () => { - const { blocks } = renderPanel("en", { - active: [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }], - setAside: [], - }); - expect(sections(blocks)[0]?.accessory).toBeUndefined(); - }); - - it("offer a review, not an ignore, for a stale entry", () => { - const { blocks } = renderPanel("en", { - active: [{ rule: "stale", severity: "medium", params: { since: "2025-01-01" } }], - setAside: [], - }); - expect(button(sections(blocks)[0])?.label).toBe("Mark as reviewed"); - }); -}); diff --git a/packages/content-freshness/tests/permissions.test.ts b/packages/content-freshness/tests/permissions.test.ts deleted file mode 100644 index 4aef9ff..0000000 --- a/packages/content-freshness/tests/permissions.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * The report exists for editors, so the route has to say so: an `admin` - * route with no declared permission dispatches at `plugins:manage`, which is - * administrators only, and every editor gets a permission error inside the - * widget card instead of the report. - * - * One route carries one permission, and this one also serves the settings - * form, so the form is held to administrators in the handler. - */ - -function fakeContext() { - const writes: Array<[string, unknown]> = []; - const scheduled: string[] = []; - const ctx = { - kv: { - async get() { - return null; - }, - async set(key: string, value: unknown) { - writes.push([key, value]); - }, - }, - cron: { - async schedule(name: string) { - scheduled.push(name); - }, - }, - log: { debug() {}, info() {}, warn() {}, error() {} }, - } as unknown as PluginContext; - return { ctx, writes, scheduled }; -} - -function adminHandler() { - const route = plugin.routes?.admin; - if (!route) throw new Error("plugin registers no admin route"); - return typeof route === "function" ? route : route.handler; -} - -async function saveSettingsAs(role: number | undefined) { - const { ctx, writes, scheduled } = fakeContext(); - const result = (await adminHandler()( - { - input: { type: "form_submit", action_id: "save_settings", values: { schedule: "0 5 * * *" } }, - request: { url: "https://example.test", method: "POST", headers: {} }, - ...(role === undefined ? {} : { user: { id: "u1", email: "u@example.test", name: null, role } }), - } as never, - ctx, - )) as { toast?: { message: string; type: string } }; - return { result, writes, scheduled }; -} - -describe("the admin route's permission", () => { - it("declares plugins:read so editors can open it", () => { - const route = plugin.routes?.admin; - expect(typeof route === "object" && route.permission).toBe("plugins:read"); - }); -}); - -describe("saving settings", () => { - it("is refused for an editor, with a reason", async () => { - const { result, writes, scheduled } = await saveSettingsAs(ROLE.EDITOR); - expect(writes).toHaveLength(0); - expect(scheduled).toHaveLength(0); - expect(result.toast?.type).toBe("error"); - expect(result.toast?.message).toMatch(/administrator/i); - }); - - it("is refused when there is no user at all", async () => { - const { result, writes } = await saveSettingsAs(undefined); - expect(writes).toHaveLength(0); - expect(result.toast?.type).toBe("error"); - }); - - it("goes through for an administrator", async () => { - const { result, writes, scheduled } = await saveSettingsAs(ROLE.ADMIN); - expect(writes.length).toBeGreaterThan(0); - // The cron is re-registered so a changed expression takes effect. - expect(scheduled).toContain("audit"); - expect(result.toast?.type).toBe("success"); - }); -}); diff --git a/packages/content-freshness/tests/plugin.test.ts b/packages/content-freshness/tests/plugin.test.ts deleted file mode 100644 index 223347e..0000000 --- a/packages/content-freshness/tests/plugin.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * Surface test. The build probes the default export for its hook and - * route keys, so a rename the manifest does not know about is a silent - * behaviour loss — this catches it. - */ -describe("plugin surface", () => { - it("registers the audit hooks", () => { - expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual([ - "content:afterDelete", - "content:afterPublish", - "content:afterRestore", - "content:afterSchedule", - "content:afterUnpublish", - "content:afterUnschedule", - "cron", - "plugin:activate", - ]); - }); - - it("registers the admin, panel and status routes", () => { - expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "panel", "status"]); - }); -}); diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts deleted file mode 100644 index 5de1434..0000000 --- a/packages/content-freshness/tests/report-paging.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { ActionsBlock, Block, ButtonElement, SectionBlock, StatsBlock } from "@emdash-cms/blocks"; -import { validateBlocks } from "@emdash-cms/blocks/server"; -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, expect, it } from "vitest"; - -import type { EntryFindings } from "../src/findings.js"; -import { VIEW_ACTION } from "../src/report.js"; - -/** - * Plugin storage returns at most 100 rows per query. The report once asked - * for 200 and sorted what came back, so on a site with more than 100 - * findings an urgent one could be missing while the stats counted it. - * Alone in this file because plugin storage outlives a host within a file. - */ - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -function row(entryId: string, rank: number): EntryFindings { - return { - collection: "posts", - entryId, - slug: entryId.toLowerCase(), - title: null, - locale: "en", - authorId: null, - rank, - hits: - rank === 0 - ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }] - : [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], - entryUpdatedAt: "2025-01-01T00:00:00.000Z", - seenIn: "2026-09-25T04:00:00.000Z", - }; -} - -const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); -const nextPage = (blocks: Block[]) => - blocks - .filter((block): block is ActionsBlock => block.type === "actions") - .flatMap((block) => block.elements) - .find((element): element is ButtonElement => element.type === "button" && element.action_id === VIEW_ACTION && element.label === "Next page"); - -it("opens with the most urgent entries however many findings there are", async () => { - host = await createPluginRuntimeTestHost(); - for (let i = 0; i < 100; i++) { - const id = `LOW${String(i).padStart(3, "0")}`; - await host.fixtures.plugin.storage("findings", `posts:${id}`, row(id, 2)); - } - await host.fixtures.plugin.storage("findings", "posts:ZURGENT", row("ZURGENT", 0)); - - const first = await host.admin.loadPage("/report"); - expect(validateBlocks(first.blocks).valid).toBe(true); - const stats = first.blocks.find((block): block is StatsBlock => block.type === "stats"); - expect(stats?.items.map((item) => item.value)).toEqual([1, 0, 100, 101]); - expect(sections(first.blocks)[0]?.text).toMatch(/^zurgent/); - - const next = nextPage(first.blocks); - expect(next).toBeDefined(); - const second = await host.admin.act("/report", VIEW_ACTION, { value: next?.value }); - expect(sections(second.blocks).length).toBeGreaterThan(0); - expect(sections(second.blocks).map((section) => section.text).join()).not.toContain("zurgent"); -}); - -it("falls back to the first page when the row the cursor points at is gone", async () => { - host = await createPluginRuntimeTestHost(); - for (let i = 0; i < 60; i++) { - const id = `CUR${String(i).padStart(3, "0")}`; - await host.fixtures.plugin.storage("findings", `posts:${id}`, row(id, 1)); - } - const first = await host.admin.loadPage("/report"); - const last = sections(first.blocks).at(-1)?.text.split(" · ")[0] as string; - // Its entry was fixed or deleted before "Next page". - await host.transport.invokeHook("content:afterDelete", { id: last.toUpperCase(), collection: "posts", permanent: true }); - - const next = await host.admin.act("/report", VIEW_ACTION, { value: nextPage(first.blocks)?.value }); - - expect(sections(next.blocks).length).toBeGreaterThan(0); -}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts deleted file mode 100644 index 9f3da38..0000000 --- a/packages/content-freshness/tests/report.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { ActionsBlock, Block, ButtonElement, ContextBlock, FormBlock, SectionBlock, SelectElement } from "@emdash-cms/blocks"; -import { validateBlocks } from "@emdash-cms/blocks/server"; -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import type { EntryFindings } from "../src/findings.js"; -import { buildReportPage, buildSettingsPage, VIEW_ACTION, viewFrom, type ReportView } from "../src/report.js"; -import { DEFAULT_SETTINGS } from "../src/settings.js"; -import type { State } from "../src/state.js"; - -const STATE: State = { sweep: null, lastFinishedAt: "2026-09-24T04:10:00.000Z" }; - -function row(slug: string, locale: string, extra: Partial = {}): EntryFindings { - return { - collection: "pages", - entryId: `01${slug.toUpperCase()}`, - slug, - title: null, - locale, - authorId: null, - rank: 1, - hits: [{ rule: "missing-description", severity: "medium", params: {} }], - entryUpdatedAt: "2026-09-17T00:00:00.000Z", - seenIn: "2026-09-24T04:00:00.000Z", - ...extra, - }; -} - -function fakeContext(rows: EntryFindings[], more = false) { - return { - schema: { - listCollections: async () => [ - { slug: "pages", label: "Pages", routable: true, urlPattern: "/{slug}", fields: [], supports: [] }, - { slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}", fields: [], supports: [] }, - ], - }, - storage: { - findings: { - count: async (where?: { rank?: number }) => rows.filter((r) => r.rank === where?.rank).length, - query: async () => ({ - items: rows.map((data) => ({ id: `${data.collection}:${data.entryId}`, data })), - hasMore: more, - ...(more ? { cursor: "next-page" } : {}), - }), - }, - }, - } as unknown as PluginContext; -} - -const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); -const contexts = (blocks: Block[]) => blocks.filter((block): block is ContextBlock => block.type === "context"); -const selects = (blocks: Block[]) => - blocks - .filter((block): block is ActionsBlock => block.type === "actions") - .flatMap((block) => block.elements) - .filter((element): element is SelectElement => element.type === "select"); -const buttons = (blocks: Block[]) => - blocks - .filter((block): block is ActionsBlock => block.type === "actions") - .flatMap((block) => block.elements) - .filter((element): element is ButtonElement => element.type === "button"); - -describe("the report's entries", () => { - it("link each entry to its editor, in its language", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "de", { title: "Über uns" })]), STATE, DEFAULT_SETTINGS, "en"); - const [section] = sections(blocks); - expect(section?.text).toContain("Über uns"); - expect(section?.accessory).toMatchObject({ - type: "link", - target: { kind: "content", collection: "pages", id: "01ABOUT", locale: "de" }, - }); - }); - - it("name the collection by its label and the priority before the findings", async () => { - const entry = row("about", "en"); - entry.hits.push({ rule: "stale", severity: "medium", params: { since: "2024-05-01" } }); - const { blocks } = await buildReportPage(fakeContext([entry]), STATE, DEFAULT_SETTINGS, "en"); - expect(sections(blocks)[0]?.text).toBe("about · Pages"); - const findings = contexts(blocks).find((block) => block.text.startsWith("Should fix:")); - expect(findings?.text).toContain("SEO description"); - expect(findings?.text).toContain("2024-05-01"); - }); - - it("tell the translations of one page apart on a multilingual site", async () => { - // Without the language, "about" and "about" read as a duplicate. - const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE, DEFAULT_SETTINGS, "en"); - expect(sections(blocks).map((section) => section.text).sort()).toEqual(["about · Pages · de", "about · Pages · en"]); - }); - - it("leave the language out on a single-language site", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE, DEFAULT_SETTINGS, "en"); - expect(sections(blocks).map((section) => section.text)).toEqual(["about · Pages", "contact · Pages"]); - }); -}); - -describe("filters and paging", () => { - const filtered: ReportView = { collection: "pages", rank: 1, cursor: null }; - - it("keep the other filter when one changes", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, DEFAULT_SETTINGS, "en", filtered); - const [collection, priority] = selects(blocks); - const posts = collection?.options.find((option) => option.label === "Posts"); - expect(viewFrom(posts?.value)).toEqual({ collection: "posts", rank: 1, cursor: null }); - const urgent = priority?.options.find((option) => option.label === "Urgent"); - expect(viewFrom(urgent?.value)).toEqual({ collection: "pages", rank: 0, cursor: null }); - // The select shows the filter in force. - expect(viewFrom(collection?.initial_value)).toEqual(filtered); - }); - - it("page within the filters", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en")], true), STATE, DEFAULT_SETTINGS, "en", filtered); - const next = buttons(blocks).find((button) => button.action_id === VIEW_ACTION); - expect(viewFrom(next?.value)).toEqual({ ...filtered, cursor: "next-page" }); - }); - - it("fall back to all collections when the filtered one is gone", async () => { - // The host rejects a select whose initial value matches no option. - const gone: ReportView = { collection: "events", rank: null, cursor: "stale" }; - const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, DEFAULT_SETTINGS, "en", gone); - expect(validateBlocks(blocks).valid).toBe(true); - expect(viewFrom(selects(blocks)[0]?.initial_value)).toEqual({ collection: null, rank: null, cursor: null }); - expect(sections(blocks)).toHaveLength(1); - }); - - it("read a view it did not write as the first page", () => { - expect(viewFrom("not json")).toEqual({ collection: null, rank: null, cursor: null }); - expect(viewFrom({ collection: 5, rank: 9, cursor: "" })).toEqual({ collection: null, rank: null, cursor: null }); - }); -}); - -describe("the settings form", () => { - it("shows the saved values, not blank fields", () => { - // Block Kit inputs render `initial_value`; anything else leaves the field empty. - const { blocks } = buildSettingsPage({ ...DEFAULT_SETTINGS, staleMonths: 18, reportMissingDescriptions: false }, "en"); - const form = blocks.find((block): block is FormBlock => block.type === "form"); - const value = (id: string) => { - const field = form?.fields.find((f) => "action_id" in f && f.action_id === id); - return field && "initial_value" in field ? field.initial_value : undefined; - }; - expect(value("staleMonths")).toBe(18); - expect(value("reportMissingDescriptions")).toBe(false); - expect(value("schedule")).toBe("0 4 * * *"); - }); -}); diff --git a/packages/content-freshness/tests/routing.test.ts b/packages/content-freshness/tests/routing.test.ts deleted file mode 100644 index 2a19b2c..0000000 --- a/packages/content-freshness/tests/routing.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { BannerBlock, Block } from "@emdash-cms/blocks"; -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import { buildReportPage, buildWidget } from "../src/report.js"; -import { DEFAULT_SETTINGS } from "../src/settings.js"; -import type { State } from "../src/state.js"; - -/** - * A routable collection without a URL pattern gets `/{collection}/{slug}` - * URLs in EmDash's sitemap. On a site that routes posts at `/aktuelles/…` - * and pages at `/{slug}`, every one of them is a 404, and nothing else in - * the admin says so. - */ - -type Collection = { slug: string; label: string; routable: boolean; urlPattern: string | null }; - -const STATE: State = { sweep: null, lastFinishedAt: null }; - -function fakeContext(collections: Collection[]) { - return { - schema: { listCollections: async () => collections.map((item) => ({ fields: [], supports: [], ...item })) }, - storage: { - findings: { - count: async () => 0, - query: async () => ({ items: [], hasMore: false }), - }, - }, - } as unknown as PluginContext; -} - -function banners(blocks: Block[]) { - return blocks.filter((block): block is BannerBlock => block.type === "banner"); -} - -describe("the URL pattern warning", () => { - it("names routable collections that fall back to /{collection}/{slug}", async () => { - const ctx = fakeContext([ - { slug: "posts", label: "Posts", routable: true, urlPattern: null }, - { slug: "pages", label: "Pages", routable: true, urlPattern: "/{slug}" }, - { slug: "authors", label: "Authors", routable: false, urlPattern: null }, - ]); - - const { blocks } = await buildReportPage(ctx, STATE, DEFAULT_SETTINGS, "en"); - const [banner] = banners(blocks); - expect(banner?.title).toContain("Posts"); - expect(banner?.title).not.toContain("Pages"); - expect(banner?.title).not.toContain("Authors"); - }); - - it("reaches the dashboard even when no entry has a finding", async () => { - const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: null }]); - const { blocks } = await buildWidget(ctx, STATE, "en"); - expect(banners(blocks)).toHaveLength(1); - }); - - it("stays out of the way when every routable collection has a pattern", async () => { - const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}" }]); - expect(banners((await buildReportPage(ctx, STATE, DEFAULT_SETTINGS, "en")).blocks)).toHaveLength(0); - expect(banners((await buildWidget(ctx, STATE, "en")).blocks)).toHaveLength(0); - }); -}); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts deleted file mode 100644 index 2ddb0a2..0000000 --- a/packages/content-freshness/tests/rules.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { describeHit } from "../src/describe.js"; -import { - DEFAULT_THRESHOLDS, - detectExpiryField, - evaluateEntry, - parseDate, - subtractMonths, - ulidTime, -} from "../src/rules.js"; -import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; - -const NOW = new Date("2026-09-08T00:00:00.000Z"); - -function entry(overrides: Partial = {}): PluginContentItem { - return { - id: "01ABC", - type: "posts", - slug: "hello", - status: "published", - locale: "en", - data: {}, - createdAt: "2026-09-01T00:00:00.000Z", - updatedAt: "2026-09-01T00:00:00.000Z", - publishedAt: "2026-09-01T00:00:00.000Z", - ...overrides, - } as PluginContentItem; -} - -const rules = (item: PluginContentItem) => evaluateEntry(item, DEFAULT_THRESHOLDS, NOW).map((hit) => hit.rule); - -describe("evaluateEntry", () => { - it("finds nothing wrong with a fresh, described entry", () => { - expect( - rules(entry({ seo: { title: null, description: "x".repeat(100), image: null, canonical: null, noIndex: false } })), - ).toEqual([]); - }); - - it("flags a published entry nobody has touched in over a year", () => { - expect(rules(entry({ updatedAt: "2024-01-01T00:00:00.000Z" }))).toContain("stale"); - }); - - it("does not call anything stale in a collection whose threshold is 0", () => { - const never = { ...DEFAULT_THRESHOLDS, staleMonths: 0, draftMonths: 0 }; - const old = "2020-01-01T00:00:00.000Z"; - expect(evaluateEntry(entry({ updatedAt: old }), never, NOW)).toEqual([]); - expect(evaluateEntry(entry({ status: "draft", updatedAt: old }), never, NOW)).toEqual([]); - }); - - it("flags a draft left for longer than the draft threshold", () => { - const found = rules(entry({ status: "draft", updatedAt: "2025-01-01T00:00:00.000Z" })); - expect(found).toContain("stale-draft"); - expect(found).not.toContain("stale"); - }); - - it("flags a schedule that passed without publishing, at high severity", () => { - const hits = evaluateEntry( - entry({ status: "draft", scheduledAt: "2026-08-01T00:00:00.000Z" }), - DEFAULT_THRESHOLDS, - NOW, - ); - const overdue = hits.find((hit) => hit.rule === "overdue-schedule"); - expect(overdue).toMatchObject({ severity: "high", params: { date: "2026-08-01", kind: "publish" } }); - }); - - it("says that a scheduled entry never went live, whatever its status", () => { - for (const status of ["draft", "scheduled"]) { - const [hit] = evaluateEntry(entry({ status, scheduledAt: "2026-08-01T00:00:00.000Z" }), DEFAULT_THRESHOLDS, NOW); - expect(describeHit("en", hit!)).toBe("Scheduled to go live on 2026-08-01, but it never did."); - } - }); - - it("flags scheduled changes to a published entry that never went live", () => { - // EmDash keeps `scheduledAt` on a published entry for scheduled draft - // changes and clears it once they publish, so a past one was missed. - const [hit] = evaluateEntry( - entry({ status: "published", scheduledAt: "2026-08-01T00:00:00.000Z" }), - DEFAULT_THRESHOLDS, - NOW, - ); - expect(hit).toMatchObject({ rule: "overdue-schedule", params: { kind: "update" } }); - }); - - it("ignores a schedule in the future, and one the scheduler has not had time to run", () => { - expect(rules(entry({ status: "draft", scheduledAt: "2027-01-01T00:00:00.000Z" }))).not.toContain( - "overdue-schedule", - ); - const minutesAgo = new Date(NOW.getTime() - 10 * 60 * 1000).toISOString(); - expect(rules(entry({ status: "draft", scheduledAt: minutesAgo }))).not.toContain("overdue-schedule"); - }); - - it("says nothing about SEO for a collection that has none", () => { - // `seo` is undefined when the collection has SEO disabled — there - // is nothing for an editor to fill in, so it is not a finding. - expect(rules(entry({ seo: undefined }))).toEqual([]); - }); - - it("flags a missing description but not its length", () => { - const found = rules( - entry({ seo: { title: null, description: " ", image: null, canonical: null, noIndex: false } }), - ); - expect(found).toEqual(["missing-description"]); - }); - - describe("an entry whose page renders a description anyway", () => { - const noSeo = { title: null, description: null, image: null, canonical: null, noIndex: false }; - - it("drops to low priority and names the field templates tend to use", () => { - // Field names from a real site whose templates fall back to them. - for (const data of [{ excerpt: "96 Prozent …" }, { hero_subheadline: "IT-Beratung …" }]) { - const [hit] = evaluateEntry(entry({ seo: noSeo, data }), DEFAULT_THRESHOLDS, NOW); - expect(hit).toMatchObject({ - rule: "missing-description", - severity: "low", - params: { field: Object.keys(data)[0] }, - }); - } - }); - - it("stays a real finding when the fallback is empty or not a fallback at all", () => { - for (const data of [{ excerpt: " " }, { hero_body: "Text" }, {}]) { - const [hit] = evaluateEntry(entry({ seo: noSeo, data }), DEFAULT_THRESHOLDS, NOW); - expect(hit?.severity).toBe("medium"); - } - }); - - it("is not reported at all once the site switches the check off", () => { - const off = { ...DEFAULT_THRESHOLDS, reportMissingDescriptions: false }; - expect(evaluateEntry(entry({ seo: noSeo }), off, NOW)).toEqual([]); - // The length check is separate and still applies. - const short = { title: null, description: "too short", image: null, canonical: null, noIndex: false }; - expect(evaluateEntry(entry({ seo: short }), off, NOW).map((hit) => hit.rule)).toEqual([ - "description-length", - ]); - }); - }); - - it("flags a description that is too short or too long", () => { - const short = rules( - entry({ seo: { title: null, description: "too short", image: null, canonical: null, noIndex: false } }), - ); - expect(short).toEqual(["description-length"]); - - const long = rules( - entry({ seo: { title: null, description: "x".repeat(400), image: null, canonical: null, noIndex: false } }), - ); - expect(long).toEqual(["description-length"]); - }); - - it("does not judge SEO on a draft", () => { - expect( - rules(entry({ status: "draft", seo: { title: null, description: "", image: null, canonical: null, noIndex: false } })), - ).toEqual([]); - }); - - it("skips date rules rather than throwing on a malformed timestamp", () => { - expect(rules(entry({ updatedAt: "not a date" }))).toEqual([]); - }); -}); - -describe("subtractMonths", () => { - it("clamps the day instead of rolling into the next month", () => { - // 31 March minus one month is February, not 3 March. - expect(subtractMonths(new Date("2026-03-31T00:00:00.000Z"), 1).toISOString().slice(0, 10)).toBe("2026-02-28"); - }); - - it("crosses a year boundary", () => { - expect(subtractMonths(new Date("2026-01-15T00:00:00.000Z"), 12).toISOString().slice(0, 10)).toBe("2025-01-15"); - }); -}); - -describe("parseDate", () => { - it("returns null for absent and malformed values", () => { - expect(parseDate(null)).toBeNull(); - expect(parseDate(undefined)).toBeNull(); - expect(parseDate("nope")).toBeNull(); - expect(parseDate("2026-01-01T00:00:00.000Z")).toBeInstanceOf(Date); - }); -}); - -/** A ULID minted at `at`: the time in its first ten characters, anything after. */ -function ulidAt(at: string): string { - const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - let ms = Date.parse(at); - let time = ""; - for (let i = 0; i < 10; i++) { - time = alphabet[ms % 32] + time; - ms = Math.floor(ms / 32); - } - return `${time}0000000000000000`; -} - -describe("ulidTime", () => { - it("reads the time a ULID was minted", () => { - // The example from the ULID specification. - expect(ulidTime("01ARYZ6S41TSV4RRFFQ69G5FAV")?.getTime()).toBe(1469918176385); - expect(ulidTime(ulidAt("2026-08-01T10:00:00.000Z"))?.toISOString()).toBe("2026-08-01T10:00:00.000Z"); - }); - - it("returns null for anything that is not a ULID", () => { - expect(ulidTime("rev-1")).toBeNull(); - expect(ulidTime(null)).toBeNull(); - }); -}); - -const REVISIONS = { expiry: null, revisions: true }; - -describe("unpublished changes", () => { - const pending = (savedAt: string, extra = {}) => - entry({ draftRevisionId: ulidAt(savedAt), liveRevisionId: ulidAt("2026-01-01T00:00:00.000Z"), ...extra }); - const found = (item: PluginContentItem, context = REVISIONS) => - evaluateEntry(item, DEFAULT_THRESHOLDS, NOW, context).map((hit) => hit.rule); - - it("are reported once they have waited longer than the threshold", () => { - const hits = evaluateEntry(pending("2026-08-01T00:00:00.000Z"), DEFAULT_THRESHOLDS, NOW, REVISIONS); - expect(hits).toContainEqual({ rule: "unpublished-changes", severity: "medium", params: { since: "2026-08-01" } }); - }); - - it("are not reported while they are recent, or when the draft is the live revision", () => { - expect(found(pending("2026-09-01T00:00:00.000Z"))).not.toContain("unpublished-changes"); - const live = ulidAt("2026-06-01T00:00:00.000Z"); - expect(found(entry({ draftRevisionId: live, liveRevisionId: live }))).not.toContain("unpublished-changes"); - expect(found(pending("2026-06-01T00:00:00.000Z", { status: "draft" }))).not.toContain("unpublished-changes"); - }); - - it("are not reported when they are scheduled to go live", () => { - // Waiting on purpose; a missed schedule is overdue-schedule's finding. - const scheduled = pending("2026-06-01T00:00:00.000Z", { scheduledAt: "2026-10-01T00:00:00.000Z" }); - expect(found(scheduled)).not.toContain("unpublished-changes"); - }); - - it("mean nothing in a collection without revisions", () => { - // A restore there can leave a draft pointer that no save clears. - expect(found(pending("2026-06-01T00:00:00.000Z"), { expiry: null, revisions: false })).not.toContain( - "unpublished-changes", - ); - }); - - it("keep an entry someone is working on from counting as stale", () => { - const old = { updatedAt: "2024-01-01T00:00:00.000Z" }; - expect(found(pending("2026-09-01T00:00:00.000Z", old))).not.toContain("stale"); - // Changes older than the stale threshold do not. - expect(found(pending("2025-01-01T00:00:00.000Z", old))).toContain("stale"); - }); - - it("date a draft by its last save, not by the row's updatedAt", () => { - // A save to a draft in a collection with revisions leaves updatedAt alone. - const draft = entry({ - status: "draft", - updatedAt: "2025-01-01T00:00:00.000Z", - draftRevisionId: ulidAt("2026-09-01T00:00:00.000Z"), - liveRevisionId: null, - }); - expect(found(draft)).not.toContain("stale-draft"); - expect(found(draft, { expiry: null, revisions: false })).toContain("stale-draft"); - }); -}); - -describe("expired entries", () => { - const context = { expiry: { slug: "valid_until", label: "Valid until" }, revisions: false }; - const until = (value: unknown, extra = {}) => - evaluateEntry(entry({ data: { valid_until: value }, ...extra }), DEFAULT_THRESHOLDS, NOW, context).map( - (hit) => hit.rule, - ); - - it("are reported once the instant in their expiry field has passed, named in UTC", () => { - // EmDash stores a date without a time as midnight in the site's - // timezone: 31 January in Berlin is 23:00 UTC the day before. - const [hit] = evaluateEntry(entry({ data: { valid_until: "2026-01-30T23:00:00.000Z" } }), DEFAULT_THRESHOLDS, NOW, context); - expect(hit).toEqual({ - rule: "expired", - severity: "medium", - params: { field: "Valid until", date: "2026-01-30 23:00 UTC" }, - }); - }); - - it("are not reported before the instant, while unpublished, or without a chosen field", () => { - expect(until("2026-09-08T00:00:01.000Z")).not.toContain("expired"); - expect(until("2026-09-01T00:00:00.000Z", { status: "draft" })).not.toContain("expired"); - expect(until(42)).not.toContain("expired"); - expect(rules(entry({ data: { valid_until: "2026-09-01T00:00:00.000Z" } }))).not.toContain("expired"); - }); -}); - -describe("detectExpiryField", () => { - const fields = (...slugs: string[]) => slugs.map((slug) => ({ slug, label: slug })); - - it("picks the first field whose name says when an entry runs out", () => { - expect(detectExpiryField(fields("start_date", "valid_until", "end_date"))?.slug).toBe("valid_until"); - }); - - it("recognises the usual names and nothing else", () => { - for (const slug of [ - "end_date", - "ends_on", - "event_end", - "end_datetime", - "expires_at", - "expiry", - "expiration_date", - "deadline", - "closing_date", - "offer_until", - "valid_to", - "valid_through", - ]) { - expect(detectExpiryField(fields(slug))?.slug).toBe(slug); - } - for (const slug of ["start_date", "published_on", "weekend", "friend_date"]) { - expect(detectExpiryField(fields(slug))).toBeNull(); - } - }); -}); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts deleted file mode 100644 index 6e97071..0000000 --- a/packages/content-freshness/tests/settings.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - applyForm, - DEFAULT_SETTINGS, - expiryFor, - normalizeSettings, - suggestedExpiry, - thresholdsFor, -} from "../src/settings.js"; - -describe("normalizeSettings", () => { - it("returns defaults for nothing stored", () => { - expect(normalizeSettings(null)).toEqual(DEFAULT_SETTINGS); - }); - - it("orders the description bounds even if they are stored the wrong way round", () => { - const settings = normalizeSettings({ descriptionMin: 200, descriptionMax: 40 }); - expect([settings.descriptionMin, settings.descriptionMax]).toEqual([40, 200]); - }); - - it("caps the page size below D1's bind limit", () => { - // A page's ids go into one `IN (...)` next to two bound values of - // storage's own; D1 allows 100 per statement. - expect(normalizeSettings({ pageSize: 100 }).pageSize).toBe(98); - }); -}); - -describe("applyForm", () => { - it("clamps numbers and ignores unknown keys", () => { - const next = applyForm(DEFAULT_SETTINGS, { staleMonths: 9999, nonsense: true }); - expect(next.staleMonths).toBe(120); - expect(next).not.toHaveProperty("nonsense"); - }); - - it("keeps values the form did not send", () => { - const current = { ...DEFAULT_SETTINGS, draftMonths: 3 }; - expect(applyForm(current, { staleMonths: 18 })).toMatchObject({ staleMonths: 18, draftMonths: 3 }); - }); -}); - -describe("settings per collection", () => { - it("override the site's thresholds, and 0 switches a rule off", () => { - const next = applyForm(DEFAULT_SETTINGS, { - "collection:pages:staleMonths": 24, - "collection:testimonials:staleMonths": 0, - "collection:testimonials:skip": false, - }); - expect(thresholdsFor(next, "pages").staleMonths).toBe(24); - expect(thresholdsFor(next, "testimonials").staleMonths).toBe(0); - expect(thresholdsFor(next, "posts").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); - }); - - it("fall back to the site's value when the field is emptied", () => { - const current = applyForm(DEFAULT_SETTINGS, { "collection:pages:staleMonths": 24 }); - // The admin leaves an emptied number field out of the submission. - const next = applyForm(current, { "collection:pages:skip": false }, ["pages"]); - expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); - expect(next.collections).toEqual({}); - }); - - it("keep the overrides of collections the form did not show", () => { - const current = applyForm(DEFAULT_SETTINGS, { "collection:archive:staleMonths": 36 }); - expect(thresholdsFor(applyForm(current, {}, ["pages"]), "archive").staleMonths).toBe(36); - }); - - it("drop an override that is not a number instead of switching the rule off", () => { - const next = normalizeSettings({ collections: { pages: { staleMonths: "abc", draftMonths: Number.NaN } } }); - expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); - expect(next.collections).toEqual({}); - }); - - it("leave a collection out of the audit", () => { - const next = applyForm(DEFAULT_SETTINGS, { "collection:legal:skip": true }); - expect(thresholdsFor(next, "legal").skip).toBe(true); - }); - - it("drop what is not a collection slug", () => { - expect(normalizeSettings({ collections: { "Bad Slug": { skip: true }, pages: "nonsense" } }).collections).toEqual({}); - }); -}); - -describe("the expiry field of a collection", () => { - const dates = [ - { slug: "starts_on", label: "Starts" }, - { slug: "valid_until", label: "Valid until" }, - ]; - - it("is checked only once the settings choose it", () => { - // Opt-in: a site that keeps past events online would otherwise get a - // finding for each of them on install. - expect(expiryFor(DEFAULT_SETTINGS, "offers", dates)).toBeNull(); - const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "valid_until" }); - expect(expiryFor(chosen, "offers", dates)).toEqual({ slug: "valid_until", label: "Valid until" }); - }); - - it("is suggested until someone decides, and no longer once they chose never", () => { - expect(suggestedExpiry(DEFAULT_SETTINGS, "offers", dates)?.slug).toBe("valid_until"); - const off = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "off" }); - expect(suggestedExpiry(off, "offers", dates)).toBeNull(); - expect(expiryFor(off, "offers", dates)).toBeNull(); - }); - - it("goes back to undecided for unset, and checks nothing for a field the collection no longer has", () => { - const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "starts_on" }); - expect(applyForm(chosen, { "collection:offers:expiryField": "unset" }).collections).toEqual({}); - const gone = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "removed_field" }); - expect(expiryFor(gone, "offers", dates)).toBeNull(); - }); -}); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts deleted file mode 100644 index 04831b2..0000000 --- a/packages/content-freshness/tests/surfaces.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { validateBlocks } from "@emdash-cms/blocks/server"; -import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; -import { PANEL_ID } from "../src/panel.js"; -import { SETTINGS_KEY } from "../src/settings.js"; -import { failNextCall } from "./bridge-calls.js"; -import { finding, newHost, runSweep, undescribed } from "./host.js"; - -/** - * The host rejects a sandboxed response whose blocks do not validate with a - * 502, which is what the report, the widget and the settings form all did - * before their blocks were typed: `stats` needs `items`, a table needs - * `page_action_id`, a form needs `fields` and `submit`. - */ - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -const valid = (response: { blocks: unknown[] }) => validateBlocks(response.blocks as never).valid; - -async function auditTask(runtime: PluginRuntimeTestHost) { - return (await runtime.inspect.scheduledTasks()).find((task) => task.name === "audit"); -} - -describe("every admin surface validates", () => { - it("before the first audit", async () => { - host = await newHost(); - expect(valid(await host.admin.loadWidget("summary"))).toBe(true); - expect(valid(await host.admin.loadPage("/report"))).toBe(true); - expect(valid(await host.admin.loadPage("/settings"))).toBe(true); - }); - - it("with findings to show", async () => { - host = await newHost(); - await undescribed(host, "posts", "no-description"); - await runSweep(host); - expect(valid(await host.admin.loadWidget("summary"))).toBe(true); - expect(valid(await host.admin.loadPage("/report"))).toBe(true); - expect(valid(await host.admin.act("/report", AUDIT_NOW_ACTION))).toBe(true); - }); -}); - -describe("the recurring audit", () => { - it("is scheduled the first time the admin shows the plugin, without an activate event", async () => { - // A plugin listed in astro.config.mjs is active from the start and - // never receives plugin:activate. - host = await newHost(); - expect(await auditTask(host)).toBeUndefined(); - - await host.admin.loadWidget("summary"); - - expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); - }); - - it("keeps its schedule when the settings name one the scheduler rejects", async () => { - host = await newHost(); - await host.admin.loadPage("/settings"); - - const response = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "every night at four" }); - - expect(response.toast?.type).toBe("error"); - expect(response.toast?.message).toContain("every night at four"); - expect(await host.inspect.kv.get(SETTINGS_KEY)).toMatchObject({ schedule: "0 4 * * *" }); - expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); - }); - - it("is registered again on activation even when the settings say it already runs", async () => { - // An uninstall that keeps the data deletes the plugin's cron tasks but - // not its KV, so on reinstall the record says "scheduled" and is wrong. - host = await newHost(); - await host.fixtures.plugin.kv(SETTINGS_KEY, { scheduledAs: "0 4 * * *" }); - - await host.actions.plugin.activate(); - - expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); - }); - - it("does not take the dashboard down when scheduling fails", async () => { - host = await newHost(); - failNextCall("cronSchedule"); - - expect(valid(await host.admin.loadWidget("summary"))).toBe(true); - expect(await auditTask(host)).toBeUndefined(); - - await host.admin.loadWidget("summary"); - expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); - }); - - it("moves to a new schedule that the scheduler accepts", async () => { - host = await newHost(); - const response = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "30 2 * * *" }); - - expect(response.toast?.type).toBe("success"); - expect(await auditTask(host)).toMatchObject({ schedule: "30 2 * * *" }); - }); -}); - -describe("the report's paging", () => { - it("falls back to the first page for a cursor storage cannot read", async () => { - host = await newHost(); - await undescribed(host, "posts", "no-description"); - await runSweep(host); - - const response = await host.admin.act("/report", "report_view", { value: { cursor: "not-a-cursor" } }); - - expect(valid(response)).toBe(true); - expect(JSON.stringify(response.blocks)).toContain("no-description"); - }); -}); - -describe("a collection left out of the audit", () => { - it("loses its findings with the next sweep, and its entries' panel says why", async () => { - host = await newHost(); - const entry = await undescribed(host, "posts", "left-out"); - await runSweep(host); - expect(await finding(host, "posts", entry.id)).not.toBeNull(); - - const saved = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:skip": true }); - expect(saved.toast?.type).toBe("success"); - await runSweep(host); - - expect(await finding(host, "posts", entry.id)).toBeNull(); - const panel = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); - expect(JSON.stringify(panel.blocks)).toContain("left out of the freshness audit"); - }); - - it("shows the collection's own settings in the form", async () => { - host = await newHost(); - await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:staleMonths": 0 }); - - const page = await host.admin.loadPage("/settings"); - - expect(valid(page)).toBe(true); - const form = page.blocks.find((block) => block.type === "form") as { fields: Array<{ action_id: string; initial_value?: unknown }> }; - expect(form.fields.find((field) => field.action_id === "collection:posts:staleMonths")?.initial_value).toBe(0); - }); -}); - -describe("the dashboard widget", () => { - it("tells an editor how many of the entries that need attention are theirs", async () => { - host = await newHost(); - const editor = await host.fixtures.user({ email: "editor@example.test", role: "editor" }); - await host.fixtures.content("posts", { slug: "editors", data: {}, status: "published", authorId: editor.id }); - await undescribed(host, "posts", "someone-elses"); - await runSweep(host); - - const widget = await host.admin.loadWidget("summary", { user: editor }); - - expect(valid(widget)).toBe(true); - expect(JSON.stringify(widget.blocks)).toContain("1 of them is yours"); - }); -}); diff --git a/packages/content-freshness/tsconfig.json b/packages/content-freshness/tsconfig.json deleted file mode 100644 index 8268866..0000000 --- a/packages/content-freshness/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "preserve", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "types": [] - }, - "include": [ - "src/**/*", - "tests/**/*", - "vitest.config.ts" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/packages/content-freshness/vitest.config.ts b/packages/content-freshness/vitest.config.ts deleted file mode 100644 index 856fcc5..0000000 --- a/packages/content-freshness/vitest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - plugins: [emdashPluginTest()], - test: { - // Most tests run the plugin inside workerd through the test host, which - // can pass vitest's 5 s default on a laptop and miss it on shared CI - // runners. The same limits as upstream's own plugin-test suite. - testTimeout: 30_000, - hookTimeout: 30_000, - // plugin-test 0.2 builds the plugin into this package's dist/ once - // per test file and reads dist/manifest.json back. Files run in - // parallel rewrite that file while others read it, and a reader that - // gets it half-written fails the whole file with "Unexpected end of - // JSON input". - fileParallelism: false, - }, -}); diff --git a/packages/content-link-check/.agents/skills b/packages/content-link-check/.agents/skills deleted file mode 120000 index 42c5394..0000000 --- a/packages/content-link-check/.agents/skills +++ /dev/null @@ -1 +0,0 @@ -../skills \ No newline at end of file diff --git a/packages/content-link-check/.claude/CLAUDE.md b/packages/content-link-check/.claude/CLAUDE.md deleted file mode 120000 index be77ac8..0000000 --- a/packages/content-link-check/.claude/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -../AGENTS.md \ No newline at end of file diff --git a/packages/content-link-check/.claude/skills b/packages/content-link-check/.claude/skills deleted file mode 120000 index 42c5394..0000000 --- a/packages/content-link-check/.claude/skills +++ /dev/null @@ -1 +0,0 @@ -../skills \ No newline at end of file diff --git a/packages/content-link-check/.gitignore b/packages/content-link-check/.gitignore deleted file mode 100644 index b947077..0000000 --- a/packages/content-link-check/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -dist/ diff --git a/packages/content-link-check/AGENTS.md b/packages/content-link-check/AGENTS.md deleted file mode 100644 index e63c328..0000000 --- a/packages/content-link-check/AGENTS.md +++ /dev/null @@ -1,14 +0,0 @@ -# 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` and `@emdash-cms/plugin-test@0.2.3` (EmDash 0.40.1); -`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. diff --git a/packages/content-link-check/README.md b/packages/content-link-check/README.md deleted file mode 100644 index 5ab1505..0000000 --- a/packages/content-link-check/README.md +++ /dev/null @@ -1,123 +0,0 @@ -# content-link-check - -Scheduled link audit for [EmDash](https://emdashcms.com) content. Finds broken -links, dead domains, redirects and malformed URLs in your entries, and reports -them in the admin. - -- Sweeps every collection on a cron schedule in small steps, one per firing - of the site's cron trigger, until the sweep is done. Each step stays - within the 10 calls a sandboxed plugin gets per run, and in-process - within Workers Free's 50 subrequests and 50 D1 queries: it reads a page of - entries and writes at most 20 rows, or tidies up, or checks about four - links. A sweep of a few hundred links finishes overnight on a per-minute - cron trigger; a slower trigger takes proportionally longer. A sweep still - running at the next scheduled start carries on rather than starting over. -- Checks internal and outbound links in published entries. Internal links are - requested from the live site rather than resolved from the database, so - hand-built Astro pages count as well as CMS entries. -- Checks each distinct link once per sweep, however many entries link to it. - The report lists it once, with the first entry it appears in and how many - more there are, and shows up to 100 findings at a time. -- Keeps the report in step with the content. Every sweep records what it - finds and then drops whatever it did not find again, so a link taken out - of an entry, or an entry unpublished or deleted, leaves the report when - the next sweep finishes. -- Reports a redirect hop on an internal link instead of following it, because - that is something EmDash's redirect engine can fix. Outbound links are - followed and judged by where they end. -- Only a clear failure counts: a 404 or 410, a domain that no longer resolves, - or a server error that persists. A 401, 403, 429 or any other answer that - does not show whether the link works is never reported: the link's last - clear result stands, and a link never clearly checked is counted as - unresolved. A site that blocks datacentre IPs therefore does not fill the - report with noise. A host that answers 429 is not asked again, for any - link, before its `Retry-After`. -- A failure has to repeat and to last before it is reported: 12 hours for a - 404 or a dead domain, 60 hours for a server error, so running two sweeps in a - row cannot report an unreachable link. A URL that does not parse is reported - on the repeat alone, since waiting cannot fix it. Once reported, a link stays - in the report until it works again. -- Reports only. It has no `content:write` capability and never edits an entry. - -It does not look for orphaned entries. Navigation usually lives in menus and -templates, which a plugin cannot read, so almost every page would look -unlinked. - -## Trust contract - -| Capability | Why | -| --- | --- | -| `content:read` | Read entries to find the links in them. | -| `schema:read` | List the collections to sweep. | -| `network:request:unrestricted` | The hosts checked are whatever your editors linked to, so no fixed allow-list can be pinned at publish time. Only `HEAD` and `GET` are issued, no content is sent, and no response body is read. | - -## Install - -Config-based, on any EmDash site and any plan: - -```js -// astro.config.mjs -import contentLinkCheck from "@eisbachcode/emdash-plugin-content-link-check"; - -emdash({ plugins: [contentLinkCheck] }); -``` - -Or under `sandboxed: []` if the site has a sandbox runner configured. Both -work the same way; see the sweep above for how each run stays within a -sandboxed plugin's allowance. - -There is no activation step. A plugin listed in the Astro config is never -"activated" the way one switched on under **Admin → Extensions** is, so the -plugin registers its nightly sweep itself. That happens the first time -someone opens the admin dashboard, the link report or its settings, or saves -or publishes an entry. - -**The site needs a cron trigger.** On Cloudflare that is `"crons"` in -`wrangler.jsonc` plus `scheduled: createScheduledHandler()` on the Worker -entry. Without one, the sweep is scheduled and never runs. The sweep advances -one step per firing, so a per-minute trigger (`"* * * * *"`) is the one to -use. - -## Settings - -| Setting | Default | | -| --- | --- | --- | -| Entries per step | 25 | Entries read per step of a sweep, up to 100. A page that links to more targets than a step may write is read in smaller pieces. It does not change how many links a step checks. | -| Schedule | `0 3 * * *` | Any cron expression. | -| Check outbound links | on | Off restricts the sweep to internal links. | -| Failures before reporting | 2 | Failed checks in a row before a link counts as broken, on top of the 12 or 60 hours above. | - -## Develop - -```sh -pnpm install -pnpm test -pnpm typecheck -pnpm build -``` - -`pnpm dev` rebuilds on save. To try it against a local site, add the built -package with a `file:` dependency and register it in `plugins: []`. - -## Compatibility - -| EmDash | Plugin test suite | -| ------ | ----------------- | -| 0.40.1 | 89 passed | -| 0.40.0 | 89 passed | -| 0.39.1 | 89 passed | -| 0.39.0 | 89 passed | - -Declared range: `emdash >=0.39.0 <1.0.0`. The table covers **every released -version inside it**, 0.39.0 through 0.40.1, so this is the whole range rather -than a sample. 0.39.0 is the floor because the plugin lists collections through -`schema:read`, which earlier versions do not have. - -Reproduce with `scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 0.40.1` from the -repo root. Last run 2026-09-26, when 0.40.1 was the latest EmDash release. A newer EmDash than the -table's top row is untested rather than unsupported: nothing blocks the install, -and this table is how you tell the difference. - -## Licence - -MIT © Eisbachcode diff --git a/packages/content-link-check/emdash-plugin.jsonc b/packages/content-link-check/emdash-plugin.jsonc deleted file mode 100644 index 5342374..0000000 --- a/packages/content-link-check/emdash-plugin.jsonc +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", - - "slug": "content-link-check", - "publisher": "did:plc:iucwrgdd464ddkranaa6ux3l", - - "license": "MIT", - "author": { "name": "Eisbachcode", "url": "https://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" } - }, - "description": "Checks internal and outbound links in your content on a schedule and reports broken links, dead domains, and redirects in the admin.", - "keywords": ["links", "seo", "audit", "maintenance"], - - // Trust contract. - // - // `network:request:unrestricted` is unavoidable here: the hosts this - // plugin contacts are whatever editors linked to, so there is no fixed - // allow-list that could be pinned at publish time. Same reasoning as - // upstream's own `webhook-notifier`. The plugin only ever issues HEAD - // and GET requests, never sends content, and reads no response body. - // - // `content:read` covers the sweep over entries, `schema:read` lists the - // collections to sweep. There is deliberately no `content:write` — this - // plugin reports, it never edits. - "capabilities": ["content:read", "schema:read", "network:request:unrestricted"], - "allowedHosts": [], - - "storage": { - // One row per distinct link target: its last check, its verdict and - // the places that link to it. `finding` is the state of a reported - // target and null otherwise, so the report and the widget can query - // it. `checkedAt` finds the targets still due in a sweep, `seenAt` - // the ones nothing links to any more. - "checks": { "indexes": ["finding", "state", "checkedAt", "seenAt"] }, - }, - - "admin": { - "pages": [ - { "path": "/report", "label": "Link report", "icon": "link" }, - { "path": "/settings", "label": "Link check settings", "icon": "settings" }, - ], - "widgets": [{ "id": "summary", "title": "Broken links", "size": "third" }], - }, -} diff --git a/packages/content-link-check/package.json b/packages/content-link-check/package.json deleted file mode 100644 index 0b5485b..0000000 --- a/packages/content-link-check/package.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "name": "@eisbachcode/emdash-plugin-content-link-check", - "version": "0.1.0", - "private": true, - "description": "EmDash plugin: scheduled link check for your content — broken links, dead domains and redirects.", - "keywords": [ - "emdash", - "emdash-plugin", - "cms", - "plugin", - "links", - "link-checker", - "seo", - "audit" - ], - "license": "MIT", - "author": "Eisbachcode ", - "homepage": "https://github.com/eisbachcode/emdash-plugins/tree/main/packages/content-link-check#readme", - "repository": { - "type": "git", - "url": "git+https://github.com/eisbachcode/emdash-plugins.git", - "directory": "packages/content-link-check" - }, - "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" - }, - "files": [ - "dist", - "emdash-plugin.jsonc" - ], - "scripts": { - "validate": "emdash-plugin validate", - "build": "emdash-plugin build", - "dev": "emdash-plugin dev", - "typecheck": "tsc --noEmit", - "test": "emdash-plugin validate && vitest run", - "bundle": "emdash-plugin bundle", - "registry:login": "emdash-plugin login", - "registry:publish": "emdash-plugin publish", - "release:setup": "emdash-plugin release setup", - "prepublishOnly": "pnpm run build" - }, - "engines": { - "node": ">=22.16" - }, - "peerDependencies": { - "emdash": ">=0.39.0" - }, - "devDependencies": { - "@eisbachcode/emdash-plugin-shared": "workspace:*", - "@emdash-cms/plugin-cli": "0.12.0", - "@emdash-cms/plugin-test": "0.2.3", - "emdash": ">=0.39.0 <1.0.0", - "typescript": "^5.9.0", - "vitest": "^4.1.0" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/packages/content-link-check/skills/creating-plugins/SKILL.md b/packages/content-link-check/skills/creating-plugins/SKILL.md deleted file mode 100644 index 336829d..0000000 --- a/packages/content-link-check/skills/creating-plugins/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -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/content-link-check/src/budget.ts b/packages/content-link-check/src/budget.ts deleted file mode 100644 index b239d94..0000000 --- a/packages/content-link-check/src/budget.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * The work one invocation may do, in the three units that run out. - * - * - **Bridge calls.** A sandboxed plugin gets `BRIDGE_BUDGET` per - * invocation: every `ctx.content`, `ctx.storage`, `ctx.kv`, `ctx.cron`, - * `ctx.log` and `ctx.http` call is one, and emdash aborts the invocation - * on the next. - * - **D1 queries.** In-process on Workers Free an invocation may make 50, - * emdash's own cron bookkeeping included. Most calls are one query, a - * content page is about three, and `putMany` is one per row. - * - **Outbound subrequests.** Also 50 in-process on Workers Free. emdash - * resolves every request's host over DNS-over-HTTPS first, A and AAAA, so - * a request is three. - * - * The plugin cannot tell whether it runs sandboxed, so every invocation - * stays inside all three. Phases draw from one `Budget` rather than counting - * for themselves, so a larger allowance only needs its number changed. - */ -export const BRIDGE_BUDGET = 10; -export const QUERY_BUDGET = 35; -export const OUTBOUND_BUDGET = 45; - -/** D1 queries of each kind of call, where it is more than one. */ -export const QUERIES = { - contentPage: 3, - cronSchedule: 2, - listCollections: 2, -} as const; - -export class Budget { - private calls = 0; - private queries = 0; - private outbound = 0; - - /** Bridge calls still available. */ - get left(): number { - return BRIDGE_BUDGET - this.calls; - } - - /** D1 queries still available. */ - get queriesLeft(): number { - return QUERY_BUDGET - this.queries; - } - - /** Outbound subrequests still available. */ - get outboundLeft(): number { - return OUTBOUND_BUDGET - this.outbound; - } - - /** - * Record bridge calls and the D1 queries behind them, made or set aside - * for calls the invocation will make last. - */ - spend(calls = 1, queries = calls): void { - this.calls += calls; - this.queries += queries; - } - - spendOutbound(subrequests: number): void { - this.outbound += subrequests; - } -} diff --git a/packages/content-link-check/src/check.ts b/packages/content-link-check/src/check.ts deleted file mode 100644 index 57d05d5..0000000 --- a/packages/content-link-check/src/check.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * The HTTP check for a single URL, and the rule that turns a run of - * checks into a finding. - * - * Kept separate from the sweep so it can be tested with a fake fetch and - * so the retry policy lives in one place. - */ - -import type { PluginContext } from "emdash/plugin"; - -export type CheckState = - /** 2xx. */ - | "ok" - /** 3xx with a Location on an internal link — often fixable with a redirect rule. */ - | "redirect" - /** 404, 410, or a domain that no longer resolves — the link is wrong. */ - | "broken" - /** Anything that does not show whether the link works. See `CheckReason`. */ - | "unknown" - /** The URL does not parse. */ - | "malformed"; - -export type CheckReason = - /** 404 or 410. */ - | "not-found" - /** The hostname has no A or AAAA record. */ - | "dead-domain" - /** 5xx. Counts as a failure, but has to last before it is reported. */ - | "server-error" - /** 401 or 407: the target wants credentials. */ - | "auth" - /** 403 or 451: the target refuses this client, often a datacentre IP. */ - | "blocked" - /** 429. */ - | "rate-limited" - /** Any other status that is neither success nor a clear failure. */ - | "unexpected-status" - /** An outbound link redirected more often than the host follows. */ - | "too-many-redirects" - /** The request never produced a response. */ - | "network-error"; - -export interface CheckResult { - state: CheckState; - reason: CheckReason | null; - status: number | null; - /** Redirect target, when `state === "redirect"`. */ - location: string | null; - /** Where an outbound link ended up, when it redirected on the way. */ - finalUrl: string | null; - /** When a rate-limited target may be asked again, as an ISO timestamp. */ - retryAfter: string | null; - /** Error message, when the request never produced a response. */ - error: string | null; - /** Fetches the check made through `ctx.http`: none, HEAD, or HEAD and GET. */ - requests: number; - /** - * Outbound subrequests those fetches may have cost in-process: three per - * request, since emdash first resolves the host over DNS-over-HTTPS (A - * and AAAA), and up to six requests for a fetch that followed redirects. - * The response does not say how many hops it took, so that is charged in - * full. - */ - outbound: number; -} - -export interface CheckOptions { - /** - * An outbound link follows its redirects and is judged by the final - * response, since the hop is not something the site can fix. An internal - * hop is the finding, because core's redirect engine can fix it. - */ - outbound: boolean; -} - -/** - * What emdash's SSRF guard throws when a hostname has no A or AAAA record. - * The guard resolves every target before the request is made, so a dead - * domain surfaces as this rejection, not as a response, and the error has - * no code to match on. Any other resolver failure (a DoH timeout, SERVFAIL) - * reads differently and stays `unknown`. - */ -const NO_ADDRESSES = "Hostname resolved to no addresses"; - -const SUBREQUESTS_PER_REQUEST = 3; -/** emdash follows at most five redirects, so a fetch is at most six requests. */ -const REQUESTS_PER_FOLLOWED_FETCH = 6; - -/** The most outbound subrequests a check can cost: HEAD and GET, each at its worst. */ -export function worstOutbound(options: CheckOptions): number { - return 2 * fetchCost(options, true); -} - -function fetchCost(options: CheckOptions, redirected: boolean): number { - return SUBREQUESTS_PER_REQUEST * (options.outbound && redirected ? REQUESTS_PER_FOLLOWED_FETCH : 1); -} - -/** - * What emdash throws after following its maximum number of redirects — - * lower-case in-process, capitalised by the sandbox bridge. - */ -const TOO_MANY_REDIRECTS = /too many redirects/i; - -export function malformedResult(error: string): CheckResult { - return { - state: "malformed", - reason: null, - status: null, - location: null, - finalUrl: null, - retryAfter: null, - error, - requests: 0, - outbound: 0, - }; -} - -export async function checkUrl(ctx: PluginContext, url: string, options: CheckOptions): Promise { - if (!ctx.http) { - return noResponse("unknown", "network-error", "network capability not granted", 0, 0); - } - - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return malformedResult("unparseable URL"); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return malformedResult(`unsupported scheme ${parsed.protocol}`); - } - - const redirect = options.outbound ? "follow" : "manual"; - let requests = 0; - let outbound = 0; - try { - requests++; - // Charged at its worst before the fetch, so one that throws is still paid for. - outbound += fetchCost(options, true); - const head = await ctx.http.fetch(url, { method: "HEAD", redirect }); - outbound += fetchCost(options, head.redirected) - fetchCost(options, true); - // Sent to the URL the HEAD ended on, which saves the redirect hops. - const retry = retryWithGet(head.status, options); - if (retry) { - requests++; - outbound += fetchCost(options, true); - } - const response = retry ? await ctx.http.fetch(head.url || url, { method: "GET", redirect }) : head; - if (retry) outbound += fetchCost(options, response.redirected) - fetchCost(options, true); - - const { state, reason } = classifyStatus(response.status, options); - const landedAt = response.redirected ? response.url : head.redirected ? head.url : ""; - const finalUrl = landedAt && landedAt !== url ? landedAt : null; - return { - state, - reason, - status: response.status, - location: state === "redirect" ? response.headers.get("location") : null, - finalUrl, - retryAfter: - reason === "rate-limited" ? parseRetryAfter(response.headers.get("retry-after"), Date.now()) : null, - error: null, - requests, - outbound, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes(NO_ADDRESSES)) return noResponse("broken", "dead-domain", message, requests, outbound); - if (TOO_MANY_REDIRECTS.test(message)) { - return noResponse("unknown", "too-many-redirects", message, requests, outbound); - } - return noResponse("unknown", "network-error", message, requests, outbound); - } -} - -function noResponse( - state: CheckState, - reason: CheckReason, - error: string, - requests: number, - outbound: number, -): CheckResult { - return { - state, - reason, - status: null, - location: null, - finalUrl: null, - retryAfter: null, - error, - requests, - outbound, - }; -} - -/** - * Whether a failed HEAD is worth a GET. Plenty of outbound servers answer - * HEAD with an error that GET does not give. The site's own pages answer - * HEAD as they answer GET, so an internal link only retries when HEAD is - * refused outright. A 429 asked us to back off and never gets a retry. - */ -function retryWithGet(status: number, options: CheckOptions): boolean { - if (status < 400 || status === 429) return false; - return options.outbound || status === 405 || status === 501; -} - -function classifyStatus( - status: number, - options: CheckOptions, -): { state: CheckState; reason: CheckReason | null } { - if (status >= 200 && status < 300) return { state: "ok", reason: null }; - // A 3xx that reaches us while following has no usable Location. - if (status >= 300 && status < 400) { - return options.outbound - ? { state: "unknown", reason: "unexpected-status" } - : { state: "redirect", reason: null }; - } - if (status === 404 || status === 410) return { state: "broken", reason: "not-found" }; - if (status === 401 || status === 407) return { state: "unknown", reason: "auth" }; - if (status === 403 || status === 451) return { state: "unknown", reason: "blocked" }; - if (status === 429) return { state: "unknown", reason: "rate-limited" }; - if (status >= 500) return { state: "unknown", reason: "server-error" }; - return { state: "unknown", reason: "unexpected-status" }; -} - -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -/** - * A `Retry-After` header as an ISO timestamp: delay-seconds or an HTTP - * date, held between a minute and a week so a hostile or broken value can - * neither hammer the target nor park the link for good. - */ -export function parseRetryAfter(value: string | null, now: number): string | null { - if (!value) return null; - const trimmed = value.trim(); - const at = /^\d+$/.test(trimmed) ? now + Number(trimmed) * 1000 : Date.parse(trimmed); - if (!Number.isFinite(at)) return null; - return new Date(Math.min(Math.max(at, now + MINUTE), now + 7 * DAY)).toISOString(); -} - -/** - * How long a failure has to persist before it is reported. The count - * threshold alone would let two manual sweeps a minute apart report a - * link. Twelve hours still reports a 404 on the second night of a nightly - * schedule; sixty hours is a server that failed three nightly sweeps. - */ -const NOT_FOUND_SPAN_MS = 12 * HOUR; -const SERVER_ERROR_SPAN_MS = 60 * HOUR; - -export interface LinkHistory { - state: CheckState; - reported: boolean; - consecutiveFailures: number; - firstFailedAt: string | null; -} - -export interface Verdict extends LinkHistory { - /** - * False when the result showed nothing and an earlier verdict stands. - * The caller then keeps that verdict's evidence too. - */ - conclusive: boolean; -} - -/** - * Fold one check result into a link's history. - * - * Only a clear failure advances the count. A blocked, rate-limited or - * unreachable target says nothing about the link either way, so the last - * conclusive verdict stands. A link already reported stays reported while - * it keeps failing, whatever the failure. - */ -export function judge( - previous: LinkHistory | null, - result: CheckResult, - now: Date, - threshold: number, -): Verdict { - if (result.state === "ok" || result.state === "redirect") { - return { - state: result.state, - reported: result.state === "redirect", - consecutiveFailures: 0, - firstFailedAt: null, - conclusive: true, - }; - } - - const serverError = result.reason === "server-error"; - if (result.state === "unknown" && !serverError) { - if (previous && previous.state !== "unknown") return { ...history(previous), conclusive: false }; - return { - state: "unknown", - reported: false, - consecutiveFailures: previous?.consecutiveFailures ?? 0, - firstFailedAt: previous?.firstFailedAt ?? null, - conclusive: true, - }; - } - - const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1; - const firstFailedAt = previous?.firstFailedAt ?? now.toISOString(); - const failingFor = now.getTime() - Date.parse(firstFailedAt); - const counted = consecutiveFailures >= threshold; - const stillFailing = previous?.reported === true && (previous.state === "broken" || previous.state === "malformed"); - - // Waiting cannot fix a URL that does not parse, so the count alone decides. - if (result.state === "malformed") { - return { - state: "malformed", - reported: stillFailing || counted, - consecutiveFailures, - firstFailedAt, - conclusive: true, - }; - } - if (serverError) { - const reported = stillFailing || (counted && failingFor >= SERVER_ERROR_SPAN_MS); - return { state: reported ? "broken" : "unknown", reported, consecutiveFailures, firstFailedAt, conclusive: true }; - } - return { - state: "broken", - reported: stillFailing || (counted && failingFor >= NOT_FOUND_SPAN_MS), - consecutiveFailures, - firstFailedAt, - conclusive: true, - }; -} - -function history(previous: LinkHistory): LinkHistory { - return { - state: previous.state, - reported: previous.reported, - consecutiveFailures: previous.consecutiveFailures, - firstFailedAt: previous.firstFailedAt ?? null, - }; -} diff --git a/packages/content-link-check/src/extract.ts b/packages/content-link-check/src/extract.ts deleted file mode 100644 index 8045ded..0000000 --- a/packages/content-link-check/src/extract.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Link extraction — pure functions, no `ctx`, no network. - * - * Deliberately schema-agnostic. Field definitions rarely say which strings - * are links: sites store them in plain `string` fields and inside repeaters - * as often as in `url` fields. So instead of knowing where links live, this - * walks the entry's `data` and picks up anything that unambiguously is one: - * - * - every `http(s)` URL appearing in any string value, wherever it sits - * — a bare URL pasted into prose counts, and so does Portable Text's - * `markDefs[].href`, which is just an `href` key a few levels down; - * - site-relative paths (`/about`), but only under keys that mean - * "this is a link" (`href`, or a name ending in one, like `cta_url`) — - * otherwise ordinary prose containing a slash would be collected as a - * link. - * - * The trade is deliberate: this over-collects slightly (a URL written in - * a code sample is indistinguishable from a real link) and never - * under-collects. A false positive shows up in the report as a link that - * resolves fine; a false negative is a broken link nobody sees. - */ - -/** One link, with where it was found, for the report table. */ -export interface ExtractedLink { - /** The href exactly as written in the content. */ - url: string; - /** Dotted path into the entry data, e.g. `body[2].markDefs[0].href`. */ - path: string; -} - -/** Keys whose string values are treated as links even when relative. */ -const LINK_KEYS = new Set(["href", "url", "canonical", "link", "src", "image"]); - -/** Custom field names ending in a link word: `cta_url`, `link_href`, `ctaHref`. */ -const LINK_KEY_SUFFIX = /(?:[_-](?:url|href|link)|[a-z0-9](?:Url|Href|Link))$/; - -function isLinkKey(key: string): boolean { - return LINK_KEYS.has(key) || LINK_KEY_SUFFIX.test(key); -} - -/** Whole-string absolute URL. */ -const ABSOLUTE = /^https?:\/\//i; - -/** - * URL-shaped substrings, for a bare URL sitting inside prose. Stops at - * whitespace and at the characters that normally *enclose* a URL rather - * than belong to it. - */ -const URL_IN_TEXT = /https?:\/\/[^\s<>"')\]]+/gi; - -/** Punctuation that ends the sentence, not the URL. */ -const TRAILING_PUNCTUATION = /[.,;:!?]+$/; - -/** - * Depth cap. Content is JSON, so there are no cycles to guard against, - * but a sandboxed invocation has a 50 ms CPU budget and a pathological - * nesting depth is not worth spending it on. - */ -const MAX_DEPTH = 12; - -/** - * Collect every link in an entry's `data` (and its `seo` block, if the - * caller merges it in). Deduplicated by URL, first path wins, order - * stable so the report does not reshuffle between sweeps. - */ -export function extractLinks(data: unknown, maxDepth: number = MAX_DEPTH): ExtractedLink[] { - const found = new Map(); - - const visit = (value: unknown, path: string, key: string | null, depth: number): void => { - if (depth > maxDepth) return; - - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return; - - // A field holding exactly one URL is the common case; scanning - // covers the rest, including a URL mid-sentence. - for (const match of trimmed.match(URL_IN_TEXT) ?? []) { - const url = ABSOLUTE.test(trimmed) && match === trimmed ? trimmed : match.replace(TRAILING_PUNCTUATION, ""); - if (url && !found.has(url)) found.set(url, path); - } - - if (key !== null && isLinkKey(key) && isRelativePath(trimmed) && !found.has(trimmed)) { - found.set(trimmed, path); - } - return; - } - - if (Array.isArray(value)) { - value.forEach((item, index) => visit(item, `${path}[${index}]`, null, depth + 1)); - return; - } - - if (value !== null && typeof value === "object") { - for (const [childKey, childValue] of Object.entries(value)) { - const childPath = path ? `${path}.${childKey}` : childKey; - visit(childValue, childPath, childKey, depth + 1); - } - } - }; - - visit(data, "", null, 0); - return [...found].map(([url, path]) => ({ url, path })); -} - -/** - * A site-relative path: starts with a single slash, and is not a - * protocol-relative URL (`//host`) or a bare slash. - */ -export function isRelativePath(value: string): boolean { - return value.startsWith("/") && !value.startsWith("//") && value.length > 1; -} - -/** Where a link points, relative to this site. */ -export type LinkScope = "internal" | "external"; - -/** - * Classify a link against the site's own origin. A same-origin absolute - * URL counts as internal — editors paste those constantly, and treating - * them as external would check the site from the outside for no reason. - */ -export function classify(url: string, siteUrl: string): LinkScope { - if (isRelativePath(url)) return "internal"; - try { - return new URL(url).origin === new URL(siteUrl).origin ? "internal" : "external"; - } catch { - return "external"; - } -} - -/** - * Absolute URL to actually request. Relative paths are resolved against - * the site origin. Returns `null` for anything that will not parse, so - * the caller can record it as malformed rather than fetch garbage. - */ -export function toRequestUrl(url: string, siteUrl: string): string | null { - try { - return new URL(url, siteUrl).toString(); - } catch { - return null; - } -} diff --git a/packages/content-link-check/src/plugin.ts b/packages/content-link-check/src/plugin.ts deleted file mode 100644 index 8e0f201..0000000 --- a/packages/content-link-check/src/plugin.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * content-link-check — scheduled link audit for EmDash content. - * - * Standard plugin format: this one file is the entry point in both - * trusted (in-process, `plugins: []`) and sandboxed (`sandboxed: []`) - * mode. The logic lives in the sibling modules; this is wiring. - */ - -import { - followUpPending, - hasRole, - isFollowUp, - ROLE, - scheduleFollowUp, - type SweepChain, -} from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; - -import { Budget } from "./budget.js"; -import { - buildReportPage, - buildSettingsPage, - buildSweepRequested, - buildWidget, - countLinks, - REPORT_PAGE_ACTION, -} from "./report.js"; -import { mergeSettings, readSettings, SETTINGS_KEY, type Settings } from "./settings.js"; -import { loadAll } from "./state.js"; -import { requestSweep, sweepTick } from "./sweep.js"; - -interface AdminInteraction { - type: string; - page?: string; - action_id?: string; - values?: Record; -} - -/** - * The recurring task, and the one-shot the "Sweep now" button schedules. - * - * They cannot share a name. `ctx.cron.schedule()` upserts on (plugin, task - * name), so scheduling the recurring name with a one-shot timestamp would - * replace the nightly sweep with a task that runs once and never again. The - * button schedules its own name instead, and the cron handler answers to - * both. - */ -const SWEEP_TASK = "sweep"; -const SWEEP_NOW_TASK = "sweep-now"; - -/** - * A sweep carries on in follow-up runs until it is done. Each run is one - * small step, so the cap is about a day of runs on a per-minute cron - * trigger; a sweep that hits it carries on from its next scheduled start. - */ -const CHAIN: SweepChain = { next: ["sweep-next-a", "sweep-next-b"], maxSteps: 1400 }; - -const plugin = { - hooks: { - /** - * Register the sweep when the plugin is switched on at runtime. A plugin - * listed in the Astro config is never switched on this way; see - * `ensureScheduled` for how that one gets its sweep. - */ - "plugin:activate": { - handler: async (_event, ctx) => scheduleSweep(ctx, (await readSettings(ctx)).schedule), - }, - - cron: { - handler: async (event, ctx) => { - const start = event.name === SWEEP_TASK || event.name === SWEEP_NOW_TASK; - if (!start && !isFollowUp(CHAIN, event.name)) return; - - const budget = new Budget(); - if (start) { - budget.spend(); - // A follow-up already waiting means a sweep is under way. - if (await followUpPending(ctx, CHAIN)) return; - } - if (!(await sweepTick(ctx, budget, { start }))) return; - // The follow-up call the run set aside pays for the warning when there is no follow-up. - if (!(await scheduleFollowUp(ctx, CHAIN, event))) { - ctx.log.warn(`Sweep paused after ${CHAIN.maxSteps} runs; it carries on from its next scheduled start`); - } - }, - }, - - /** A site whose admin nobody opens still gets its sweep; see `ensureScheduled`. */ - "content:afterSave": { - errorPolicy: "continue", - handler: async (_event, ctx) => ensureScheduled(ctx), - }, - "content:afterPublish": { - errorPolicy: "continue", - handler: async (_event, ctx) => ensureScheduled(ctx), - }, - }, - - routes: { - /** Every Block Kit surface arrives here. */ - admin: { - // Without this the route resolves with no permission and dispatch - // falls back to `plugins:manage`, which is administrators only, so - // every editor sees the widget card and a permission error inside - // it. `plugins:read` is editor and above. Settings submissions are - // held to administrators inside the handler, because a route carries - // one permission and this one serves both a report and a form. - permission: "plugins:read", - handler: async (routeCtx, ctx) => { - const interaction = routeCtx.input as AdminInteraction; - - if (interaction.type === "page_load") { - const { settings, state, requestedAt } = await loadAll(ctx); - await ensureScheduled(ctx, settings); - if (interaction.page === "widget:summary") return buildWidget(ctx, state, requestedAt); - if (interaction.page === "/settings") return buildSettingsPage(settings); - return buildReportPage(ctx); - } - - if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { - const current = await readSettings(ctx); - if (!hasRole(routeCtx.user, ROLE.ADMIN)) { - return { - ...buildSettingsPage(current), - toast: { message: "Only an administrator can change these settings.", type: "error" }, - }; - } - const next = mergeSettings(current, interaction.values ?? {}); - // Scheduled before anything is stored: emdash rejects a cron - // expression it cannot parse, and a saved value the sweep does not - // run on would be worse than a refused save. - try { - await scheduleSweep(ctx, next.schedule); - } catch { - return { - ...buildSettingsPage(current), - toast: { message: "That schedule is not a cron expression emdash accepts. Nothing was saved.", type: "error" }, - }; - } - await ctx.kv.set(SETTINGS_KEY, next); - return buildSettingsPage(next); - } - - if (interaction.type === "block_action") { - if (interaction.action_id === "sweep_now") { - await requestSweep(ctx); - await ctx.cron?.schedule(SWEEP_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); - return buildSweepRequested(); - } - if (interaction.action_id === REPORT_PAGE_ACTION) return buildReportPage(ctx); - } - - return { blocks: [] }; - }, - }, - - /** JSON counts, for a status check or an external dashboard. */ - status: { - handler: async (_routeCtx, ctx) => { - const [{ state }, counts] = await Promise.all([loadAll(ctx), countLinks(ctx)]); - return { - phase: state.phase, - startedAt: state.startedAt, - finishedAt: state.finishedAt, - collections: state.collections, - counts, - }; - }, - }, - }, -} satisfies SandboxedPlugin; - -/** - * `satisfies` above for the per-hook inference (`event` typed by hook - * name), and an explicit type on the export because the inferred shape - * cannot be named from a pnpm nested path — `tsc` rejects the generated - * declaration with TS2742 otherwise. - */ -export default plugin as SandboxedPlugin; - -async function scheduleSweep(ctx: PluginContext, schedule: string): Promise { - await ctx.cron?.schedule(SWEEP_TASK, { schedule }); -} - -/** Set once this isolate has seen the sweep registered on the saved schedule. */ -let sweepConfirmed = false; - -/** - * Register the nightly sweep if it is missing or runs on another schedule - * than the saved one. emdash only fires `plugin:activate` when a plugin is - * switched on at runtime, never for one listed in the Astro config, and the - * manifest cannot declare a schedule. So admin page loads and saves check, - * once per isolate. A failure here never costs the caller the work it came - * for. - */ -async function ensureScheduled(ctx: PluginContext, known?: Settings): Promise { - if (sweepConfirmed || !ctx.cron) return; - try { - const [tasks, settings] = await Promise.all([ctx.cron.list(), known ?? readSettings(ctx)]); - const sweep = tasks.find((task) => task.name === SWEEP_TASK); - if (sweep?.schedule !== settings.schedule) await scheduleSweep(ctx, settings.schedule); - sweepConfirmed = true; - } catch { - // Logging would spend a bridge call the caller may not have; the next - // load or save tries again. - } -} diff --git a/packages/content-link-check/src/report.ts b/packages/content-link-check/src/report.ts deleted file mode 100644 index 9c55080..0000000 --- a/packages/content-link-check/src/report.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Block Kit surfaces: the report page, the settings form, and the - * dashboard widget. - * - * Block Kit is the whole admin UI for a sandboxed plugin — `table` - * carries sorting and pagination, `stats` gives the metric cards, and - * `empty` covers the clean state. No React, and no editor slot needed. - */ - -import type { PluginContext } from "emdash/plugin"; - -import type { Settings } from "./settings.js"; -import type { SweepState } from "./state.js"; -import { FINDINGS, type CheckRow } from "./store.js"; - -type Block = Record; - -/** Block Kit requires a table to name the action its paging and sorting send. */ -export const REPORT_PAGE_ACTION = "report_page"; - -const REPORT_LIMIT = 100; - -export interface Counts { - broken: number; - redirect: number; - unknown: number; - /** Targets checked at least once. */ - total: number; -} - -/** Counts of distinct targets, so the widget and the report agree. */ -export async function countLinks(ctx: PluginContext): Promise { - const [broken, redirect, unknown, total] = await Promise.all([ - ctx.storage.checks.count({ finding: { in: ["broken", "malformed"] } }), - ctx.storage.checks.count({ finding: "redirect" }), - ctx.storage.checks.count({ state: "unknown" }), - ctx.storage.checks.count({ state: { in: ["ok", "redirect", "broken", "unknown", "malformed"] } }), - ]); - return { broken, redirect, unknown, total }; -} - -function progress(state: SweepState, requestedAt: string | null): string { - if (requestedAt && requestedAt > (state.startedAt ?? "")) { - return "A full sweep has been requested; it starts with the next run of the site's cron trigger."; - } - if (state.phase === "done") { - return state.finishedAt ? `Last sweep finished ${state.finishedAt.slice(0, 10)}.` : "No sweep has run yet."; - } - return `A sweep is under way (${state.phase === "check" ? "checking links" : "reading content"}).`; -} - -export async function buildWidget( - ctx: PluginContext, - state: SweepState, - requestedAt: string | null, -): Promise<{ blocks: Block[] }> { - const counts = await countLinks(ctx); - - if (counts.total === 0) { - return { - blocks: [ - { - type: "empty", - icon: "link", - title: "Nothing checked yet", - description: - state.phase === "done" && !state.finishedAt && !requestedAt - ? "The first sweep runs on the schedule in Link check settings." - : progress(state, requestedAt), - }, - ], - }; - } - - return { - blocks: [ - { - type: "stats", - items: [ - { label: "Broken", value: String(counts.broken) }, - { label: "Redirects", value: String(counts.redirect) }, - { label: "Checked", value: String(counts.total) }, - ], - }, - { type: "context", text: progress(state, requestedAt) }, - ], - }; -} - -/** What the report shows right after "Run a full sweep now". */ -export function buildSweepRequested(): { blocks: Block[] } { - return { - blocks: [ - { type: "header", text: "Link report" }, - { - type: "context", - text: "A full sweep has been requested. It starts with the next run of the site's cron trigger; reload this page to follow it.", - }, - ], - }; -} - -/** - * Reported targets, one row each, with the first place it is linked from - * and how many more there are. A table row per place would let one link - * repeated across a few hundred entries push the page past emdash's Block - * Kit limits, and emdash then rejects the whole report. - */ -export async function buildReportPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { - const counts = await countLinks(ctx); - const findings = await ctx.storage.checks.query({ where: { finding: { in: FINDINGS } }, limit: REPORT_LIMIT }); - const checks = findings.items - .map(({ data }) => data as CheckRow) - .sort((a, b) => a.state.localeCompare(b.state) || a.target.localeCompare(b.target)); - - if (checks.length === 0) { - return { - blocks: [ - { type: "header", text: "Link report" }, - { - type: "empty", - icon: "check", - title: counts.total === 0 ? "Nothing checked yet" : "No problems found", - description: - counts.total === 0 - ? "The first sweep runs on the schedule in Link check settings." - : `${counts.total} links checked, none broken.`, - }, - ], - }; - } - - const multilingual = new Set(checks.map((check) => check.places?.[0]?.locale ?? null)).size > 1; - const reported = counts.broken + counts.redirect; - - return { - blocks: [ - { type: "header", text: "Link report" }, - { - type: "stats", - items: [ - { label: "Broken", value: String(counts.broken) }, - { label: "Redirects", value: String(counts.redirect) }, - { label: "Unresolved", value: String(counts.unknown) }, - { label: "Checked", value: String(counts.total) }, - ], - }, - ...(reported > checks.length - ? [{ type: "context", text: `Showing ${checks.length} of ${reported} findings.` }] - : []), - { - type: "table", - columns: [ - { key: "state", label: "State" }, - { key: "status", label: "Status" }, - { key: "url", label: "Link" }, - { key: "entry", label: "Found in" }, - ...(multilingual ? [{ key: "locale", label: "Language" }] : []), - { key: "where", label: "Field" }, - { key: "since", label: "First seen" }, - ], - rows: checks.map((check) => { - const first = check.places?.[0]; - const more = (check.placeCount ?? 0) - 1; - return { - state: check.state, - status: check.status === null ? "—" : String(check.status), - url: first?.url ?? check.target, - entry: first - ? `${first.collection}/${first.entrySlug ?? first.entryId}${more > 0 ? ` +${more} more` : ""}` - : "—", - locale: first?.locale ?? "", - where: first?.path ?? "", - since: (check.firstSeenAt ?? check.checkedAt).slice(0, 10), - }; - }), - page_action_id: REPORT_PAGE_ACTION, - }, - { - type: "actions", - elements: [{ type: "button", action_id: "sweep_now", label: "Run a full sweep now" }], - }, - ], - }; -} - -export function buildSettingsPage(settings: Settings): { blocks: Block[] } { - return { - blocks: [ - { type: "header", text: "Link check settings" }, - { - type: "context", - text: "Every collection on the site is checked.", - }, - { - type: "form", - submit: { label: "Save", action_id: "save_settings" }, - fields: [ - { - type: "number_input", - action_id: "batchSize", - label: "Entries per step", - min: 1, - max: 100, - initial_value: settings.batchSize, - }, - { - type: "text_input", - action_id: "schedule", - label: "Schedule (cron)", - initial_value: settings.schedule, - }, - { - type: "toggle", - action_id: "checkExternal", - label: "Check outbound links", - initial_value: settings.checkExternal, - }, - { - type: "number_input", - action_id: "failureThreshold", - label: "Failures before reporting", - min: 1, - max: 10, - initial_value: settings.failureThreshold, - }, - ], - }, - ], - }; -} diff --git a/packages/content-link-check/src/settings.ts b/packages/content-link-check/src/settings.ts deleted file mode 100644 index 43b28f7..0000000 --- a/packages/content-link-check/src/settings.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Settings, stored in plugin KV as one object under `settings`, so any - * invocation reads them in one bridge call. - * - * A standard-format (sandboxed) plugin has no declarative - * `settingsSchema` — that is a native-plugin feature. So the form is - * built by hand in `report.ts` and written through here, which is also - * what upstream's own `webhook-notifier` does. - */ - -import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -export { clampNumber }; - -export const SETTINGS_KEY = "settings"; - -export interface Settings { - /** Entries read per step of the walk over the site's content. */ - batchSize: number; - /** Cron expression for the sweep. */ - schedule: string; - /** Check outbound links, not just internal ones. */ - checkExternal: boolean; - /** Consecutive failures before a link is reported as broken. */ - failureThreshold: number; -} - -export const DEFAULT_SETTINGS: Settings = { - batchSize: 25, - schedule: "0 3 * * *", - checkExternal: true, - failureThreshold: 2, -}; - -/** Settings from whatever is stored, each value falling back to its default. */ -export function settingsFrom(stored: unknown): Settings { - const raw = (stored ?? {}) as Partial>; - return { - batchSize: clampNumber(raw.batchSize, 1, 100, DEFAULT_SETTINGS.batchSize), - schedule: - typeof raw.schedule === "string" && raw.schedule.trim() ? raw.schedule.trim() : DEFAULT_SETTINGS.schedule, - checkExternal: typeof raw.checkExternal === "boolean" ? raw.checkExternal : DEFAULT_SETTINGS.checkExternal, - failureThreshold: clampNumber(raw.failureThreshold, 1, 10, DEFAULT_SETTINGS.failureThreshold), - }; -} - -export async function readSettings(ctx: PluginContext): Promise { - return settingsFrom(await ctx.kv.get(SETTINGS_KEY)); -} - -/** Submitted form values over the current settings; unknown keys are ignored. */ -export function mergeSettings(current: Settings, values: Record): Settings { - return settingsFrom({ - batchSize: values.batchSize ?? current.batchSize, - schedule: typeof values.schedule === "string" && values.schedule.trim() ? values.schedule : current.schedule, - checkExternal: typeof values.checkExternal === "boolean" ? values.checkExternal : current.checkExternal, - failureThreshold: values.failureThreshold ?? current.failureThreshold, - }); -} diff --git a/packages/content-link-check/src/state.ts b/packages/content-link-check/src/state.ts deleted file mode 100644 index a9db2f9..0000000 --- a/packages/content-link-check/src/state.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Where the sweep stands, stored in plugin KV as one object under `state` - * so a tick reads it, together with the settings, in one bridge call. - */ - -import type { PluginContext } from "emdash/plugin"; - -import { SETTINGS_KEY, settingsFrom, type Settings } from "./settings.js"; - -export const STATE_KEY = "state"; - -/** - * When "Run a full sweep now" was last pressed. Its own key, because a - * running tick rewrites the whole state object and would drop a request - * written into it. - */ -export const REQUEST_KEY = "sweep-requested"; - -export interface SweepState { - /** - * `begin` asks the next run to start a sweep, `walk` reads the content - * and records every link, `prune` deletes what the walk no longer found, - * `check` requests the targets not yet checked this sweep. - */ - phase: "begin" | "walk" | "prune" | "check" | "done"; - /** When the current or last sweep began. Null before the first one. */ - startedAt: string | null; - finishedAt: string | null; - /** Collection slugs, fixed when the sweep begins. */ - collections: string[]; - collectionIndex: number; - cursor: string | null; - /** - * Entries per walk step: the setting, shrunk when a page has more targets - * than a step may write, and grown back afterwards. - */ - pageSize: number | null; - /** Hosts that answered 429, each with the time it may be asked again. */ - backoff: Record; -} - -export const IDLE_STATE: SweepState = { - phase: "done", - startedAt: null, - finishedAt: null, - collections: [], - collectionIndex: 0, - cursor: null, - pageSize: null, - backoff: {}, -}; - -export function stateFrom(stored: unknown): SweepState { - return { ...IDLE_STATE, ...((stored ?? {}) as Partial) }; -} - -/** Settings, sweep state and any pending sweep request, in one bridge call. */ -export async function loadAll( - ctx: PluginContext, -): Promise<{ settings: Settings; state: SweepState; requestedAt: string | null }> { - const entries = new Map((await ctx.kv.list()).map(({ key, value }) => [key, value])); - const requested = entries.get(REQUEST_KEY); - return { - settings: settingsFrom(entries.get(SETTINGS_KEY)), - state: stateFrom(entries.get(STATE_KEY)), - requestedAt: typeof requested === "string" ? requested : null, - }; -} - -/** A fresh sweep over `collections`, starting now. */ -export function beginSweep(state: SweepState, collections: string[], now: Date): SweepState { - return { - ...state, - phase: "walk", - startedAt: now.toISOString(), - finishedAt: null, - collections, - collectionIndex: 0, - cursor: null, - pageSize: null, - }; -} diff --git a/packages/content-link-check/src/store.ts b/packages/content-link-check/src/store.ts deleted file mode 100644 index 03a54a9..0000000 --- a/packages/content-link-check/src/store.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * The stored record: one row per link target — the URL actually requested - * — checked once and shared by every entry that links to it. - * - * A row also carries the first few places that link to it and how many - * there are, rebuilt by every sweep. Keeping them on the row rather than one - * row per link keeps a sweep's writes to one per target, which matters on - * Workers Free: `putMany` issues one query per row and an invocation may - * make 50. `seenAt` is the start of the last sweep that found the target - * linked; a sweep deletes what it did not stamp, which is how a removed - * link, an unpublished entry or a deleted one leaves the report. - */ - -import type { CheckReason, LinkHistory } from "./check.js"; - -export type Finding = "broken" | "malformed" | "redirect"; - -export const FINDINGS: Finding[] = ["broken", "malformed", "redirect"]; - -/** Places kept on a row; the rest are only counted. */ -export const PLACES_KEPT = 3; - -/** - * Ids or values one `IN` list may carry. D1 binds at most 100 parameters - * per query and emdash's plugin storage adds its own two, without chunking. - */ -export const MAX_IDS = 48; - -export interface Place { - /** The link exactly as written in the entry. */ - url: string; - /** Dotted path into the entry data, e.g. `body[2].markDefs[0].href`. */ - path: string; - collection: string; - entryId: string; - entrySlug: string | null; - locale: string | null; -} - -export interface CheckRow extends Omit { - /** `pending` until the target's first check. */ - state: LinkHistory["state"] | "pending"; - /** The URL requested, without its fragment. Also the row id. */ - target: string; - scope: "internal" | "external"; - reason: CheckReason | null; - status: number | null; - location: string | null; - finalUrl: string | null; - error: string | null; - /** `state` once the target is reported, null until then. */ - finding: Finding | null; - /** Empty until the first check, so a range query finds it as due. */ - checkedAt: string; - firstSeenAt: string; - seenAt: string; - /** The walk page that last stamped the row, so a page read again does not count its places twice. */ - lastPage: string; - places: Place[]; - placeCount: number; -} - -export interface Stored { - id: string; - data: T; -} - -export function findingFor(verdict: LinkHistory): Finding | null { - if (!verdict.reported) return null; - return FINDINGS.find((finding) => finding === verdict.state) ?? null; -} - -/** A target nothing has checked yet. */ -export function pendingCheck(target: string, scope: CheckRow["scope"], seenAt: string, places: Place[], placeCount: number): CheckRow { - return { - target, - scope, - state: "pending", - reason: null, - status: null, - location: null, - finalUrl: null, - error: null, - consecutiveFailures: 0, - firstFailedAt: null, - reported: false, - finding: null, - checkedAt: "", - firstSeenAt: seenAt, - seenAt, - lastPage: "", - places, - placeCount, - }; -} diff --git a/packages/content-link-check/src/sweep.ts b/packages/content-link-check/src/sweep.ts deleted file mode 100644 index a62ba0d..0000000 --- a/packages/content-link-check/src/sweep.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * The sweep, one bounded step at a time. - * - * Every invocation draws from one `Budget` of bridge calls, D1 queries and - * outbound subrequests (see `budget.ts`), so each phase batches its storage - * work and stops when the budget would not cover its next step: - * - * 1. **walk** — read a page of published entries and write one row per - * target they link to, stamped with the sweep's start and carrying the - * places it is linked from. A target not seen before is stored as - * pending. - * 2. **prune** — delete the targets this sweep did not stamp: links taken - * out, entries unpublished or deleted. - * 3. **check** — request the targets not checked since the sweep began, - * as many as the budget allows, and store the verdicts. - * - * The phase, collection, cursor and host backoff live in one KV object. - * Every step can be repeated: a run that dies before saving the state is - * followed by one that redoes its step without counting anything twice. - */ - -import { listCollections } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; - -import { QUERIES, type Budget } from "./budget.js"; -import { checkUrl, judge, worstOutbound, type LinkHistory } from "./check.js"; -import { classify, extractLinks, toRequestUrl, type LinkScope } from "./extract.js"; -import type { Settings } from "./settings.js"; -import { beginSweep, loadAll, REQUEST_KEY, STATE_KEY, type SweepState } from "./state.js"; -import { findingFor, MAX_IDS, PLACES_KEPT, pendingCheck, type CheckRow, type Place } from "./store.js"; - -/** HEAD, and the GET a failed HEAD may earn. */ -const CALLS_PER_LINK = 2; -/** A page linking to more targets than this is read in fewer entries. */ -const PAGE_TARGETS = 20; -/** Targets of one entry that are recorded; an entry linking to more is cut short. */ -const ENTRY_TARGETS = 2 * MAX_IDS; - -/** - * One run of the sweep. `start` is a scheduled start: it begins a new sweep - * if none is under way, and otherwise lets the current one carry on. A - * requested sweep begins at whichever run comes next. Returns whether there - * is more to do. - */ -export async function sweepTick(ctx: PluginContext, budget: Budget, options: { start: boolean }): Promise { - const { settings, state: loaded, requestedAt } = await loadAll(ctx); - budget.spend(); - - const requested = requestedAt !== null && requestedAt > (loaded.startedAt ?? ""); - let state = (options.start && loaded.phase === "done") || requested ? { ...loaded, phase: "begin" as const } : loaded; - if (state.phase === "done") return false; - if (state.phase === "begin") { - const collections = (await listCollections(ctx)).map((collection) => collection.slug); - budget.spend(1, QUERIES.listCollections); - state = beginSweep(state, collections, new Date()); - } - - // The state write and the follow-up that end every run that did work. - budget.spend(2, 1 + QUERIES.cronSchedule); - - while (state.phase === "walk") { - const step = await walk(ctx, budget, settings, state); - state = step.state; - if (!step.progressed) break; - } - while (state.phase === "prune" && budget.left >= 2 && budget.queriesLeft >= 2) state = await prune(ctx, budget, state); - if (state.phase === "check" && budget.left >= 2 + CALLS_PER_LINK && budget.queriesLeft >= 3) { - state = await check(ctx, budget, settings, state); - } - - await ctx.kv.set(STATE_KEY, state); - return state.phase !== "done"; -} - -/** Asks the next run to begin a fresh sweep. */ -export async function requestSweep(ctx: PluginContext): Promise { - await ctx.kv.set(REQUEST_KEY, new Date().toISOString()); -} - -interface Linked { - scope: LinkScope; - places: Place[]; - count: number; -} - -async function walk( - ctx: PluginContext, - budget: Budget, - settings: Settings, - state: SweepState, -): Promise<{ state: SweepState; progressed: boolean }> { - const collection = state.collections[state.collectionIndex]; - if (collection === undefined || !ctx.content || !state.startedAt) { - return { state: { ...state, phase: "prune", cursor: null }, progressed: true }; - } - // A page, one lookup and one write, at the least. - if (budget.left < 3 || budget.queriesLeft < QUERIES.contentPage + 2) return { state, progressed: false }; - const seenAt = state.startedAt; - const pageSize = state.pageSize ?? settings.batchSize; - const pageKey = `${collection}:${state.cursor ?? ""}:${pageSize}`; - - const page = await ctx.content.list(collection, { - limit: pageSize, - where: { status: "published" }, - ...(state.cursor ? { cursor: state.cursor } : {}), - }); - budget.spend(1, QUERIES.contentPage); - - const linked = linksIn(page.items, collection, ctx.site.url, settings); - const targets = [...linked.keys()]; - if (targets.length > PAGE_TARGETS && page.items.length > 1) { - const smaller = Math.max(1, Math.floor((page.items.length * PAGE_TARGETS) / targets.length)); - return { state: { ...state, pageSize: smaller }, progressed: true }; - } - - const lookups = chunk(targets.slice(0, ENTRY_TARGETS), MAX_IDS); - if (budget.left < lookups.length + 1 || budget.queriesLeft < lookups.length + 1) return { state, progressed: false }; - const known = new Map(); - for (const ids of lookups) { - for (const [id, row] of await ctx.storage.checks.getMany(ids)) known.set(id, row as CheckRow); - budget.spend(); - } - - const writes = lookups.flat().flatMap((target) => { - const row = stamped(known.get(target), target, linked.get(target)!, seenAt, pageKey); - return row ? [{ id: target, data: row }] : []; - }); - const now = writes.slice(0, budget.queriesLeft); - if (now.length > 0) { - await ctx.storage.checks.putMany(now); - budget.spend(1, now.length); - } - // What did not fit is written when the page is read again; what was - // written is then recognised by its page key and skipped. - if (now.length < writes.length) return { state, progressed: false }; - - const grown = Math.min(settings.batchSize, pageSize * 2); - const next = { ...state, pageSize: grown >= settings.batchSize ? null : grown }; - return { - state: page.cursor - ? { ...next, cursor: page.cursor } - : { ...next, collectionIndex: state.collectionIndex + 1, cursor: null }, - progressed: true, - }; -} - -/** Every target the entries link to, with where, in the order found. */ -function linksIn( - entries: Array<{ id: string; slug?: string | null; locale?: string | null; data: unknown; seo?: unknown }>, - collection: string, - siteUrl: string, - settings: Settings, -): Map { - const linked = new Map(); - for (const entry of entries) { - for (const link of extractLinks({ ...(entry.data as object), seo: entry.seo })) { - const scope = classify(link.url, siteUrl); - if (scope === "external" && !settings.checkExternal) continue; - const requestUrl = toRequestUrl(link.url, siteUrl); - const target = requestUrl ? withoutFragment(requestUrl) : link.url; - const seen = linked.get(target) ?? { scope, places: [], count: 0 }; - seen.count++; - if (seen.places.length < PLACES_KEPT) { - seen.places.push({ - url: link.url, - path: link.path, - collection, - entryId: entry.id, - entrySlug: entry.slug ?? null, - locale: entry.locale ?? null, - }); - } - linked.set(target, seen); - } - } - return linked; -} - -/** - * A target's row with this sweep's stamp, or null when this page already - * stamped it. The places start over with each sweep, so an entry that - * stopped linking drops out of them. - */ -function stamped( - known: CheckRow | undefined, - target: string, - found: Linked, - seenAt: string, - pageKey: string, -): CheckRow | null { - if (!known) return { ...pendingCheck(target, found.scope, seenAt, found.places, found.count), lastPage: pageKey }; - const sameSweep = known.seenAt === seenAt; - if (sameSweep && known.lastPage === pageKey) return null; - return { - ...known, - seenAt, - lastPage: pageKey, - places: [...(sameSweep ? (known.places ?? []) : []), ...found.places].slice(0, PLACES_KEPT), - placeCount: (sameSweep ? (known.placeCount ?? 0) : 0) + found.count, - }; -} - -async function prune(ctx: PluginContext, budget: Budget, state: SweepState): Promise { - const stale = await ctx.storage.checks.query({ - where: { seenAt: { lt: state.startedAt ?? "" } }, - limit: MAX_IDS, - }); - budget.spend(); - if (stale.items.length === 0) return { ...state, phase: "check" }; - - await ctx.storage.checks.deleteMany(stale.items.map((item) => item.id)); - budget.spend(); - return state; -} - -async function check(ctx: PluginContext, budget: Budget, settings: Settings, state: SweepState): Promise { - const due = await ctx.storage.checks.query({ - where: { checkedAt: { lt: state.startedAt ?? "" } }, - limit: Math.max(1, Math.min(MAX_IDS, budget.queriesLeft - 2, budget.left)), - }); - budget.spend(); - if (due.items.length === 0) return { ...state, phase: "done", finishedAt: new Date().toISOString() }; - - // The `putMany` below; its rows are charged once they are known. - budget.spend(1, 0); - const backoff = { ...state.backoff }; - const updates: Array<{ id: string; data: CheckRow }> = []; - for (const { id, data } of due.items) { - if (budget.queriesLeft - updates.length < 1) break; - const row = data as CheckRow; - const now = new Date(); - const host = hostOf(row.target); - const backoffUntil = host ? backoff[host] : undefined; - if (backoffUntil && Date.parse(backoffUntil) > now.getTime()) { - // Counted as checked for this sweep, so a host that keeps a whole - // batch waiting cannot stall the targets behind it. - updates.push({ id, data: { ...row, checkedAt: now.toISOString() } }); - continue; - } - const options = { outbound: row.scope === "external" }; - if (budget.left < CALLS_PER_LINK || budget.outboundLeft < worstOutbound(options)) break; - - const result = await checkUrl(ctx, row.target, options); - budget.spend(result.requests, 0); - budget.spendOutbound(result.outbound); - if (host && result.retryAfter) backoff[host] = result.retryAfter; - - const previous = row.state === "pending" ? null : (row as LinkHistory); - const verdict = judge(previous, result, now, settings.failureThreshold); - updates.push({ - id, - data: - !verdict.conclusive && previous - ? { ...row, checkedAt: now.toISOString() } - : { - ...row, - state: verdict.state, - reason: result.reason, - status: result.status, - location: result.location, - finalUrl: result.finalUrl, - error: result.error, - consecutiveFailures: verdict.consecutiveFailures, - firstFailedAt: verdict.firstFailedAt, - reported: verdict.reported, - finding: findingFor(verdict), - checkedAt: now.toISOString(), - }, - }); - } - - if (updates.length > 0) { - await ctx.storage.checks.putMany(updates); - budget.spend(0, updates.length); - } - return { ...state, backoff: current(backoff) }; -} - -function chunk(items: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); - return chunks; -} - -function withoutFragment(url: string): string { - const parsed = new URL(url); - parsed.hash = ""; - return parsed.href; -} - -function hostOf(target: string): string { - try { - return new URL(target).host; - } catch { - return ""; - } -} - -function current(backoff: Record): Record { - const now = Date.now(); - return Object.fromEntries(Object.entries(backoff).filter(([, until]) => Date.parse(until) > now)); -} diff --git a/packages/content-link-check/tests/admin-route.test.ts b/packages/content-link-check/tests/admin-route.test.ts deleted file mode 100644 index 5c9a844..0000000 --- a/packages/content-link-check/tests/admin-route.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -/** - * emdash validates every Block Kit response before the admin sees it, and - * rejects the whole page for one malformed block. Going through the real - * route is the only way to catch that. - */ - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -const admin = { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }; - -async function load(runtime: PluginRuntimeTestHost, page: string): Promise>> { - const response = await runtime.actions.routes.request("admin", { - method: "POST", - headers: { "x-emdash-request": "1" }, - body: { type: "page_load", page }, - user: admin, - }); - const body = (await response.json()) as { data?: { blocks?: unknown }; error?: { code: string } }; - if (!Array.isArray(body.data?.blocks)) throw new Error(`${page} was rejected: ${JSON.stringify(body.error)}`); - return body.data.blocks as Array>; -} - -describe("the admin surfaces", () => { - it("are accepted by emdash once there are results to show", async () => { - host = await createPluginRuntimeTestHost(); - await host.fixtures.site({ url: "https://example.test" }); - await host.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], - }); - await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/old-page" }, status: "published" }); - await host.http.respond( - "https://example.test/old-page", - new Response(null, { status: 301, headers: { location: "/new-page" } }), - ); - - const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); - host.scheduled.setTime(`${night}T02:59:00.000Z`); - await host.actions.plugin.activate(); - host.scheduled.setTime(`${night}T03:00:05.000Z`); - for (let runs = 0; runs < 20; runs++) { - if ((await host.scheduled.run()).processed === 0) break; - } - - for (const page of ["widget:summary", "/report", "/settings"]) { - const blocks = await load(host, page); - expect(blocks.length).toBeGreaterThan(0); - } - expect((await load(host, "widget:summary")).some((block) => block.type === "stats")).toBe(true); - }); -}); - -describe("a plugin listed in the Astro config", () => { - // emdash never fires `plugin:activate` for it, so nothing here calls - // `actions.plugin.activate()`. - it("gets its nightly sweep from the first admin page load", async () => { - host = await createPluginRuntimeTestHost(); - await host.fixtures.site({ url: "https://example.test" }); - await host.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], - }); - await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/page" }, status: "published" }); - await host.http.respond("https://example.test/page", new Response(null, { status: 200 })); - - const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); - host.scheduled.setTime(`${night}T02:59:00.000Z`); - await load(host, "widget:summary"); - host.scheduled.setTime(`${night}T03:00:05.000Z`); - for (let runs = 0; runs < 20; runs++) { - if ((await host.scheduled.run()).processed === 0) break; - } - - expect(await host.inspect.storage.list("checks")).toHaveLength(1); - }); - - it("gets its nightly sweep from a content change, with no admin page opened", async () => { - host = await createPluginRuntimeTestHost(); - await host.fixtures.site({ url: "https://example.test" }); - await host.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], - }); - const post = await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/page" }, status: "draft" }); - await host.http.respond("https://example.test/page", new Response(null, { status: 200 })); - - const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); - host.scheduled.setTime(`${night}T02:59:00.000Z`); - await host.actions.content.publish("posts", post.id); - // Content hooks run deferred, after the action has answered. - await vi.waitFor(async () => expect(await host?.inspect.scheduledTasks()).not.toEqual([])); - host.scheduled.setTime(`${night}T03:00:05.000Z`); - for (let runs = 0; runs < 20; runs++) { - if ((await host.scheduled.run()).processed === 0) break; - } - - expect(await host.inspect.storage.list("checks")).toHaveLength(1); - }); -}); diff --git a/packages/content-link-check/tests/bridge-calls.ts b/packages/content-link-check/tests/bridge-calls.ts deleted file mode 100644 index 6a22554..0000000 --- a/packages/content-link-check/tests/bridge-calls.ts +++ /dev/null @@ -1,66 +0,0 @@ -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[] = []; -/** For each call, the longest array it was given: ids for getMany/deleteMany, rows for putMany. */ -const sizes: number[] = []; -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); - sizes.push(Math.max(0, ...args.map((arg) => (Array.isArray(arg) ? arg.length : 0)))); - 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 { - return (await bridgeCallsWithSizes(invocation)).map(({ name }) => name); -} - -/** As `bridgeCalls`, with the longest array each call was given. */ -export async function bridgeCallsWithSizes( - invocation: () => Promise, -): Promise> { - install(); - calls.length = 0; - sizes.length = 0; - await invocation(); - return calls.map((name, i) => ({ name, size: sizes[i] ?? 0 })); -} - -/** - * 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/content-link-check/tests/budget.test.ts b/packages/content-link-check/tests/budget.test.ts deleted file mode 100644 index eea2856..0000000 --- a/packages/content-link-check/tests/budget.test.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { SweepState } from "../src/state.js"; -import { bridgeCalls, bridgeCallsWithSizes } from "./bridge-calls.js"; - -/** - * The platform's limits, as of emdash 0.40 on Cloudflare. They are what the - * plugin must stay under, so they are written out here rather than taken - * from the plugin's own constants. - */ -/** Bridge calls a sandboxed invocation may make. */ -const SANDBOX_CALLS = 10; -/** Ids one storage call may bind: D1 allows 100 parameters and emdash's plugin storage adds two. */ -const D1_IDS = 98; -/** - * D1 queries a run may make in-process on Workers Free: 50 per invocation, - * less what emdash's cron runner spends claiming and finishing the task. - */ -const D1_QUERIES = 40; -/** Outbound subrequests an in-process invocation may make on Workers Free. */ -const OUTBOUND = 50; -/** emdash resolves each request's host over DNS-over-HTTPS (A and AAAA) before making it. */ -const SUBREQUESTS_PER_REQUEST = 3; -/** D1 queries of each bridge call, from emdash's implementation; `putMany` is one per row. */ -const QUERIES_OF: Record = { contentList: 3, cronSchedule: 2, schemaListCollections: 2 }; - -/** - * Every invocation against the bridge-call budget. - * - * A sandboxed invocation that makes one call too many is aborted on - * Cloudflare, and the local harness enforces no limit, so these count the - * calls. The sweep tests measure every run of whole sweeps rather than one - * hand-picked run per phase, and check the sweep finished, so a fixture - * too small to reach a phase's worst case cannot pass quietly. - */ - -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -const ENTRIES = 30; -const FIXTURE_HEAVY_MS = 60_000; -const admin = { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }; - -function night(daysAhead: number, time: string): string { - const day = new Date(Date.now() + daysAhead * 86_400_000).toISOString().slice(0, 10); - return `${day}T${time}.000Z`; -} - -/** - * Answers for one night of checks. Each entry links to its own internal - * page, an outbound page whose HEAD is refused (so the check spends a GET - * too), an outbound page that redirects once and one that redirects five - * times. One shared host rate-limits. - */ -async function answerOneNight(runtime: PluginRuntimeTestHost): Promise { - for (let i = 0; i < ENTRIES; i++) { - await runtime.http.respond(`https://example.test/page-${i}`, new Response(null, { status: 200 })); - await runtime.http.respond(`https://guarded${i}.test/`, new Response(null, { status: 403 })); - await runtime.http.respond(`https://guarded${i}.test/`, new Response(null, { status: 200 })); - await runtime.http.respond( - `https://moved${i}.test/`, - new Response(null, { status: 301, headers: { location: `https://landed${i}.test/` } }), - ); - await runtime.http.respond(`https://landed${i}.test/`, new Response(null, { status: 200 })); - // Five redirects, as many as emdash follows. - for (let hop = 1; hop <= 5; hop++) { - await runtime.http.respond( - `https://chain${i}.test/${hop}`, - new Response(null, { status: 302, headers: { location: `/${hop + 1}` } }), - ); - } - await runtime.http.respond(`https://chain${i}.test/6`, new Response(null, { status: 200 })); - } - await runtime.http.respond( - "https://busy.test/a", - new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }), - ); - await runtime.http.respond( - "https://busy.test/b", - new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }), - ); -} - -async function site(): Promise<{ runtime: PluginRuntimeTestHost; ids: string[] }> { - const runtime = await createPluginRuntimeTestHost(); - await runtime.fixtures.site({ url: "https://example.test" }); - await runtime.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: ["cta_url", "guarded_url", "moved_url", "chain_url", "busy_url"].map((slug) => ({ - slug, - label: slug, - type: "string" as const, - })), - }); - const ids: string[] = []; - for (let i = 0; i < ENTRIES; i++) { - const entry = await runtime.fixtures.content("posts", { - slug: `post-${i}`, - status: "published", - data: { - cta_url: `/page-${i}`, - guarded_url: `https://guarded${i}.test/`, - moved_url: `https://moved${i}.test/`, - chain_url: `https://chain${i}.test/1`, - busy_url: i % 2 === 0 ? "https://busy.test/a" : "https://busy.test/b", - }, - }); - ids.push(entry.id); - } - return { runtime, ids }; -} - -interface Run { - calls: Array<{ name: string; size: number }>; - /** Requests the plugin's fetches made, every redirect hop included. */ - requests: number; -} - -/** Run the cron executor until nothing is due, returning what every run did. */ -async function sweepRuns(runtime: PluginRuntimeTestHost): Promise { - const runs: Run[] = []; - for (let i = 0; i < 600; i++) { - let processed = 0; - const before = runtime.http.requests().length; - const calls = await bridgeCallsWithSizes(async () => { - processed = (await runtime.scheduled.run()).processed; - }); - if (processed === 0) return runs; - runs.push({ calls, requests: runtime.http.requests().length - before }); - } - throw new Error("the sweep did not end"); -} - -/** Every run that breaks one of the platform's limits, described. */ -function over(runs: Run[]): string[] { - return runs.flatMap(({ calls, requests }) => { - const names = calls.map(({ name }) => name); - const queries = calls.reduce( - (sum, { name, size }) => sum + (name === "storagePutMany" ? size : (QUERIES_OF[name] ?? 1)), - 0, - ); - const widestIn = Math.max( - 0, - ...calls.filter(({ name }) => name === "storageGetMany" || name === "storageDeleteMany").map(({ size }) => size), - ); - const outbound = requests * SUBREQUESTS_PER_REQUEST; - const broken = [ - names.length > SANDBOX_CALLS && `${names.length} bridge calls`, - queries > D1_QUERIES && `${queries} D1 queries`, - widestIn > D1_IDS && `${widestIn} ids in one call`, - outbound > OUTBOUND && `${outbound} outbound subrequests`, - ].filter(Boolean); - return broken.length > 0 ? [`${broken.join(", ")}: ${names.join(", ")}`] : []; - }); -} - -describe("a sweep", () => { - it( - "never makes a run spend more than the budget, first sweep and a pruning one alike", - async () => { - const { runtime, ids } = await site(); - host = runtime; - - await answerOneNight(runtime); - runtime.scheduled.setTime(night(1, "02:59:00")); - await runtime.actions.plugin.activate(); - runtime.scheduled.setTime(night(1, "03:00:05")); - const first = await sweepRuns(runtime); - expect(over(first)).toEqual([]); - expect((await runtime.inspect.kv.get("state"))?.phase).toBe("done"); - // Four links of each entry's own, and two busy.test links they share. - expect(await runtime.inspect.storage.list("checks")).toHaveLength(ENTRIES * 4 + 2); - - // More than one delete's worth, so the prune's id cap is exercised. - for (const id of ids.slice(0, 20)) await runtime.actions.content.unpublish("posts", id); - await answerOneNight(runtime); - runtime.scheduled.setTime(night(2, "03:00:05")); - const second = await sweepRuns(runtime); - expect(over(second)).toEqual([]); - expect((await runtime.inspect.kv.get("state"))?.phase).toBe("done"); - expect(await runtime.inspect.storage.list("checks")).toHaveLength((ENTRIES - 20) * 4 + 2); - }, - FIXTURE_HEAVY_MS, - ); -}); - -describe("the admin and the hooks", () => { - /** A config install: never activated, so the first surface also has to register the sweep. */ - async function configInstall(): Promise { - const runtime = await createPluginRuntimeTestHost(); - await runtime.fixtures.site({ url: "https://example.test" }); - await runtime.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: [{ slug: "cta_url", label: "cta", type: "string" }], - }); - // One link in enough entries that the report has a long list of places to summarise. - for (let i = 0; i < 250; i++) { - await runtime.fixtures.content("posts", { slug: `post-${i}`, status: "published", data: { cta_url: "/old" } }); - } - await runtime.http.respond( - "https://example.test/old", - new Response(null, { status: 301, headers: { location: "/new" } }), - ); - return runtime; - } - - function admin_(runtime: PluginRuntimeTestHost, body: Record) { - return () => - runtime.actions.routes.request("admin", { - method: "POST", - headers: { "x-emdash-request": "1" }, - body, - user: admin, - }); - } - - it( - "stay within the budget on every surface, each at its worst", - async () => { - host = await configInstall(); - const surfaces = { - widget: admin_(host, { type: "page_load", page: "widget:summary" }), - report: admin_(host, { type: "page_load", page: "/report" }), - settings: admin_(host, { type: "page_load", page: "/settings" }), - }; - - // Each surface first on a fresh isolate's worth of state: the sweep - // is not registered yet, so the check has to schedule it too. - const firstLoad = await bridgeCalls(surfaces.widget); - expect(firstLoad, firstLoad.join(", ")).toContain("cronSchedule"); - expect(firstLoad.length, firstLoad.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); - - const night1 = night(1, "03:00:05"); - host.scheduled.setTime(night1); - await sweepRuns(host); - - for (const [name, load] of Object.entries(surfaces)) { - const calls = await bridgeCalls(load); - expect(calls.length, `${name}: ${calls.join(", ")}`).toBeLessThanOrEqual(SANDBOX_CALLS); - } - - const save = await bridgeCalls( - admin_(host, { type: "form_submit", action_id: "save_settings", values: { schedule: "0 4 * * *" } }), - ); - expect(save.length, save.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); - - const sweepNow = await bridgeCalls(admin_(host, { type: "block_action", action_id: "sweep_now" })); - expect(sweepNow.length, sweepNow.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); - - const status = await bridgeCalls(() => host!.actions.routes.request("status", { user: admin })); - expect(status.length, status.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); - }, - FIXTURE_HEAVY_MS, - ); - - it("stay within the budget in a content hook that has to register the sweep", async () => { - host = await createPluginRuntimeTestHost(); - await host.fixtures.site({ url: "https://example.test" }); - await host.fixtures.collection({ slug: "posts", label: "Posts" }); - const draft = await host.fixtures.content("posts", { slug: "post", status: "draft", data: {} }); - - const calls = await bridgeCalls(async () => { - await host!.actions.content.publish("posts", draft.id); - // Content hooks run deferred, after the action has answered. - await vi.waitFor(async () => expect(await host?.inspect.scheduledTasks()).not.toEqual([])); - }); - expect(calls, calls.join(", ")).toContain("cronSchedule"); - expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); - }); -}); diff --git a/packages/content-link-check/tests/check.test.ts b/packages/content-link-check/tests/check.test.ts deleted file mode 100644 index 807a7d5..0000000 --- a/packages/content-link-check/tests/check.test.ts +++ /dev/null @@ -1,364 +0,0 @@ -import { createUnrestrictedHttpAccess } from "emdash"; -import type { PluginContext } from "emdash/plugin"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { checkUrl, judge, parseRetryAfter, type CheckResult, type Verdict } from "../src/check.js"; - -const INTERNAL = { outbound: false }; -const EXTERNAL = { outbound: true }; - -describe("checkUrl", () => { - it.each([ - [200, "ok", null], - [404, "broken", "not-found"], - [410, "broken", "not-found"], - [401, "unknown", "auth"], - [407, "unknown", "auth"], - [403, "unknown", "blocked"], - [451, "unknown", "blocked"], - [429, "unknown", "rate-limited"], - [400, "unknown", "unexpected-status"], - [500, "unknown", "server-error"], - [503, "unknown", "server-error"], - ])("calls a %i %s", async (status, state, reason) => { - const ctx = fakeCtx(async () => new Response(null, { status })); - expect(await checkUrl(ctx, "https://target.test/x", EXTERNAL)).toMatchObject({ state, reason, status }); - }); - - it("retries an outbound link with GET when HEAD fails", async () => { - const calls: string[] = []; - const ctx = fakeCtx(async (_url, init) => { - calls.push(String(init?.method)); - return new Response(null, { status: calls.length === 1 ? 403 : 200 }); - }); - - const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); - expect(calls).toEqual(["HEAD", "GET"]); - expect(result).toMatchObject({ state: "ok", requests: 2 }); - }); - - it("does not send a GET when HEAD succeeds", async () => { - const calls: string[] = []; - const ctx = fakeCtx(async (_url, init) => { - calls.push(String(init?.method)); - return new Response(null, { status: 200 }); - }); - - const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); - expect(calls).toEqual(["HEAD"]); - expect(result.requests).toBe(1); - }); - - it("charges three subrequests a request, and a followed redirect at its worst", async () => { - const plain = fakeCtx(async () => new Response(null, { status: 200 })); - expect((await checkUrl(plain, "https://target.test/x", EXTERNAL)).outbound).toBe(3); - - const refusedHead = fakeCtx(async (_url, init) => new Response(null, { status: init?.method === "HEAD" ? 403 : 200 })); - expect((await checkUrl(refusedHead, "https://target.test/x", EXTERNAL)).outbound).toBe(6); - - const followed = fakeCtx(async () => landed(new Response(null, { status: 200 }), "https://elsewhere.test/")); - expect((await checkUrl(followed, "https://target.test/x", EXTERNAL)).outbound).toBe(18); - - const failing = fakeCtx(async () => { - throw new Error("connection reset"); - }); - expect((await checkUrl(failing, "https://target.test/x", EXTERNAL)).outbound).toBe(18); - expect((await checkUrl(failing, "https://example.test/page", INTERNAL)).outbound).toBe(3); - }); - - it("does not ask a rate-limited host again with GET", async () => { - const calls: string[] = []; - const ctx = fakeCtx(async (_url, init) => { - calls.push(String(init?.method)); - return new Response(null, { status: 429 }); - }); - - await checkUrl(ctx, "https://target.test/x", EXTERNAL); - expect(calls).toEqual(["HEAD"]); - }); - - it("retries an internal link with GET only when HEAD is refused", async () => { - const methodsFor = async (status: number) => { - const calls: string[] = []; - const ctx = fakeCtx(async (_url, init) => { - calls.push(String(init?.method)); - return new Response(null, { status: calls.length === 1 ? status : 200 }); - }); - await checkUrl(ctx, "https://example.test/page", INTERNAL); - return calls; - }; - - expect(await methodsFor(404)).toEqual(["HEAD"]); - expect(await methodsFor(405)).toEqual(["HEAD", "GET"]); - }); - - it("keeps where an outbound link landed when HEAD redirected and GET finished the check", async () => { - const requested: string[] = []; - const ctx = fakeCtx(async (url, init) => { - requested.push(`${String(init?.method)} ${url}`); - return init?.method === "HEAD" - ? landed(new Response(null, { status: 405 }), "https://b.test/y") - : new Response(null, { status: 200 }); - }); - - const result = await checkUrl(ctx, "http://a.test/x", EXTERNAL); - expect(requested).toEqual(["HEAD http://a.test/x", "GET https://b.test/y"]); - expect(result).toMatchObject({ state: "ok", finalUrl: "https://b.test/y" }); - }); - - it("reports an internal redirect hop instead of following it", async () => { - const ctx = fakeCtx( - async () => new Response(null, { status: 301, headers: { location: "https://target.test/new" } }), - ); - const result = await checkUrl(ctx, "https://target.test/old", INTERNAL); - expect(result).toMatchObject({ state: "redirect", status: 301, location: "https://target.test/new" }); - }); - - it("keeps the time a rate-limited target asked for", async () => { - const ctx = fakeCtx(async () => new Response(null, { status: 429, headers: { "retry-after": "3600" } })); - const before = Date.now(); - const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); - const retryAt = Date.parse(result.retryAfter ?? ""); - expect(retryAt).toBeGreaterThanOrEqual(before + 3600_000); - expect(retryAt).toBeLessThanOrEqual(Date.now() + 3600_000); - }); - - it("turns a network error into an unresolved result", async () => { - const ctx = fakeCtx(async () => { - throw new Error("dns failure"); - }); - const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); - expect(result).toMatchObject({ state: "unknown", reason: "network-error", error: "dns failure" }); - }); - - it("recognises the sandbox bridge's redirect limit", async () => { - const ctx = fakeCtx(async () => { - throw new Error("Too many redirects (max 5)"); - }); - const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); - expect(result).toMatchObject({ state: "unknown", reason: "too-many-redirects" }); - }); - - describe("through emdash's own guarded fetch", () => { - // emdash resolves every target over DNS-over-HTTPS before it fetches, - // with the global fetch, and follows redirects itself. Stubbing the DNS - // answer runs the real guard and redirect loop, so a changed upstream - // error message or redirect behaviour fails here instead of in - // production. - afterEach(() => { - vi.unstubAllGlobals(); - }); - - function stubDns(status: number, address?: string) { - vi.stubGlobal("fetch", async (input: string | URL | Request) => { - const type = new URL(input instanceof Request ? input.url : String(input)).searchParams.get("type"); - const answer = address && type === "A" ? [{ type: 1, data: address }] : []; - return Response.json({ Status: status, Answer: answer }); - }); - } - - function guardedCtx(targets: Record Response>) { - const http = createUnrestrictedHttpAccess("content-link-check", async (input) => { - const url = input instanceof Request ? input.url : String(input); - const target = targets[url]; - if (!target) throw new Error(`unexpected request to ${url}`); - return target(); - }); - return { http } as unknown as PluginContext; - } - - it("reports a domain that no longer exists as broken", async () => { - stubDns(3); - const result = await checkUrl(guardedCtx({}), "https://gone.test/page", EXTERNAL); - expect(result).toMatchObject({ state: "broken", reason: "dead-domain" }); - }); - - it("leaves a failed DNS lookup unresolved", async () => { - stubDns(2); - const result = await checkUrl(guardedCtx({}), "https://flaky.test/page", EXTERNAL); - expect(result.state).toBe("unknown"); - }); - - it("judges an outbound link by where its redirects end", async () => { - stubDns(0, "93.184.216.34"); - const ctx = guardedCtx({ - "https://old.test/": () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }), - "https://new.test/": () => new Response(null, { status: 200 }), - }); - const result = await checkUrl(ctx, "https://old.test/", EXTERNAL); - expect(result).toMatchObject({ state: "ok", finalUrl: "https://new.test/" }); - }); - - it("calls an outbound link broken when its redirect ends in a 404", async () => { - stubDns(0, "93.184.216.34"); - const ctx = guardedCtx({ - "https://old.test/": () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }), - "https://new.test/": () => new Response(null, { status: 404 }), - }); - const result = await checkUrl(ctx, "https://old.test/", EXTERNAL); - expect(result).toMatchObject({ state: "broken", reason: "not-found" }); - }); - - it("leaves an endless redirect unresolved", async () => { - stubDns(0, "93.184.216.34"); - const ctx = guardedCtx({ - "https://loop.test/a": () => new Response(null, { status: 302, headers: { location: "/b" } }), - "https://loop.test/b": () => new Response(null, { status: 302, headers: { location: "/a" } }), - }); - const result = await checkUrl(ctx, "https://loop.test/a", EXTERNAL); - expect(result).toMatchObject({ state: "unknown", reason: "too-many-redirects" }); - }); - }); - - it("rejects non-http schemes without a request", async () => { - let called = false; - const ctx = fakeCtx(async () => { - called = true; - return new Response(null, { status: 200 }); - }); - const result = await checkUrl(ctx, "mailto:hi@example.com", EXTERNAL); - expect(result.state).toBe("malformed"); - expect(called).toBe(false); - }); - - it("is unresolved rather than broken without the network capability", async () => { - const ctx = { http: undefined } as unknown as PluginContext; - expect((await checkUrl(ctx, "https://target.test/x", EXTERNAL)).state).toBe("unknown"); - }); -}); - -describe("parseRetryAfter", () => { - const now = Date.parse("2026-09-25T12:00:00.000Z"); - - it("reads delay-seconds and HTTP dates", () => { - expect(parseRetryAfter("120", now)).toBe("2026-09-25T12:02:00.000Z"); - expect(parseRetryAfter("Fri, 25 Sep 2026 14:00:00 GMT", now)).toBe("2026-09-25T14:00:00.000Z"); - }); - - it("holds the wait between a minute and a week", () => { - expect(parseRetryAfter("0", now)).toBe("2026-09-25T12:01:00.000Z"); - expect(parseRetryAfter("99999999", now)).toBe("2026-10-02T12:00:00.000Z"); - }); - - it("ignores a value it cannot read", () => { - expect(parseRetryAfter("soon", now)).toBeNull(); - expect(parseRetryAfter(null, now)).toBeNull(); - }); -}); - -describe("judge", () => { - const THRESHOLD = 2; - const start = new Date("2026-09-25T03:00:00.000Z"); - const hoursLater = (hours: number) => new Date(start.getTime() + hours * 3_600_000); - - /** Fold a series of checks, made at the given times, into one verdict. */ - function run(checks: Array<[CheckResult, Date]>): Verdict { - let verdict: Verdict | null = null; - for (const [result, at] of checks) verdict = judge(verdict, result, at, THRESHOLD); - if (!verdict) throw new Error("no checks"); - return verdict; - } - - it("does not report a 404 found twice within minutes", () => { - const verdict = run([ - [notFound, start], - [notFound, new Date(start.getTime() + 60_000)], - ]); - expect(verdict).toMatchObject({ state: "broken", consecutiveFailures: 2, reported: false }); - }); - - it("reports a 404 on the second nightly sweep", () => { - expect( - run([ - [notFound, start], - [notFound, hoursLater(24)], - ]).reported, - ).toBe(true); - }); - - it("reports a server error only once it has lasted, and only then calls it broken", () => { - const threeNights: Array<[CheckResult, Date]> = [ - [serverError, start], - [serverError, hoursLater(24)], - [serverError, hoursLater(48)], - ]; - expect(run(threeNights)).toMatchObject({ state: "unknown", reported: false }); - expect(run([...threeNights, [serverError, hoursLater(72)]])).toMatchObject({ state: "broken", reported: true }); - }); - - it("lets the last verdict stand on an answer that shows nothing", () => { - const verdict = run([ - [notFound, start], - [blocked, hoursLater(24)], - ]); - expect(verdict).toMatchObject({ - state: "broken", - consecutiveFailures: 1, - firstFailedAt: start.toISOString(), - reported: false, - conclusive: false, - }); - - const resumed = run([ - [notFound, start], - [blocked, hoursLater(24)], - [notFound, hoursLater(48)], - ]); - expect(resumed.reported).toBe(true); - }); - - it("calls a first check that shows nothing unresolved", () => { - expect(run([[blocked, start]])).toMatchObject({ state: "unknown", reported: false, conclusive: true }); - }); - - it("keeps a reported link reported through an answer that shows nothing", () => { - const verdict = run([ - [notFound, start], - [notFound, hoursLater(24)], - [blocked, hoursLater(48)], - ]); - expect(verdict).toMatchObject({ state: "broken", reported: true }); - }); - - it("keeps a reported 404 reported when its server starts failing", () => { - const verdict = run([ - [notFound, start], - [notFound, hoursLater(24)], - [serverError, hoursLater(48)], - ]); - expect(verdict).toMatchObject({ state: "broken", reported: true }); - }); - - it("clears the failure run when the link works again", () => { - const verdict = run([ - [notFound, start], - [ok, hoursLater(24)], - ]); - expect(verdict).toMatchObject({ state: "ok", consecutiveFailures: 0, firstFailedAt: null }); - }); -}); - -const ok: CheckResult = { - state: "ok", - reason: null, - status: 200, - location: null, - finalUrl: null, - retryAfter: null, - error: null, - requests: 1, - outbound: 3, -}; -const notFound: CheckResult = { ...ok, state: "broken", reason: "not-found", status: 404 }; -const serverError: CheckResult = { ...ok, state: "unknown", reason: "server-error", status: 503 }; -const blocked: CheckResult = { ...ok, state: "unknown", reason: "blocked", status: 403 }; - -/** A response as emdash hands it back after following redirects to `url`. */ -function landed(response: Response, url: string): Response { - Object.defineProperties(response, { url: { value: url }, redirected: { value: true } }); - return response; -} - -function fakeCtx(fetch: (url: string, init?: RequestInit) => Promise) { - return { http: { fetch } } as unknown as PluginContext; -} diff --git a/packages/content-link-check/tests/cron-names.test.ts b/packages/content-link-check/tests/cron-names.test.ts deleted file mode 100644 index 7d3bb88..0000000 --- a/packages/content-link-check/tests/cron-names.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * The "Sweep now" button schedules a one-shot task under its own name, - * because `ctx.cron.schedule()` upserts on (plugin, task name): scheduling - * the recurring name with a one-shot timestamp would replace the nightly - * sweep instead of running once. So the name has to differ, and the cron - * handler has to answer to both. - */ - -/** Minimal context: a tick starts by reading plugin KV, then finds nothing to walk. */ -function fakeContext() { - const warns: string[] = []; - const reads: string[] = []; - const nothing = { items: [], cursor: undefined }; - const ctx = { - kv: { - async list() { - reads.push("kv.list"); - return []; - }, - async set() {}, - }, - schema: { listCollections: async () => [] }, - storage: { - checks: { query: async () => nothing }, - }, - log: { - debug() {}, - info() {}, - warn(message: string) { - warns.push(message); - }, - error() {}, - }, - } as unknown as PluginContext; - return { ctx, warns, reads }; -} - -function cronHandler() { - const entry = plugin.hooks?.cron; - if (!entry) throw new Error("plugin registers no cron hook"); - return typeof entry === "function" ? entry : entry.handler; -} - -async function run(name: string) { - const { ctx, warns, reads } = fakeContext(); - await cronHandler()({ name, scheduledAt: "2026-09-20T03:00:00.000Z" }, ctx); - return { warns, reads }; -} - -describe("cron task names", () => { - it("runs for the recurring sweep", async () => { - const { reads } = await run("sweep"); - expect(reads).not.toHaveLength(0); - }); - - it("runs for the one-shot the Sweep now button schedules", async () => { - // Without this the button schedules a task that fires and is - // discarded, so the button appears to do nothing at all. - const { reads } = await run("sweep-now"); - expect(reads).not.toHaveLength(0); - }); - - it("reaches the same code path for both names", async () => { - const recurring = await run("sweep"); - const oneShot = await run("sweep-now"); - expect(oneShot.warns).toEqual(recurring.warns); - expect(oneShot.reads).toEqual(recurring.reads); - }); - - it("ignores a task belonging to something else", async () => { - const { reads, warns } = await run("some-other-plugins-task"); - expect(reads).toHaveLength(0); - expect(warns).toHaveLength(0); - }); -}); diff --git a/packages/content-link-check/tests/discovery.test.ts b/packages/content-link-check/tests/discovery.test.ts deleted file mode 100644 index b120ffe..0000000 --- a/packages/content-link-check/tests/discovery.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * A fresh install has no settings at all. The sweep has to find the site's - * collections itself, or the plugin sits idle until someone discovers a - * settings field and types the collection names in. - */ - -function fakeContext(collections: Record) { - const kv = new Map(); - const listed: Array<{ collection: string; status: unknown }> = []; - const noRows = { items: [], cursor: undefined }; - const ctx = { - kv: { - get: async (key: string) => kv.get(key) ?? null, - set: async (key: string, value: unknown) => void kv.set(key, value), - list: async () => [...kv].map(([key, value]) => ({ key, value })), - }, - schema: { - listCollections: async () => Object.keys(collections).map((slug) => ({ slug })), - }, - content: { - list: async (collection: string, options?: { where?: { status?: unknown } }) => { - listed.push({ collection, status: options?.where?.status }); - return { - items: (collections[collection] ?? []).map((id) => ({ id, status: "published", data: {} })), - cursor: undefined, - }; - }, - }, - storage: { - checks: { query: async () => noRows, getMany: async () => new Map(), putMany: async () => {} }, - }, - cron: { list: async () => [], schedule: async () => {} }, - site: { url: "https://example.test" }, - log: { debug() {}, info() {}, warn() {}, error() {} }, - } as unknown as PluginContext; - return { ctx, listed }; -} - -async function tick(ctx: PluginContext, name: string) { - const entry = plugin.hooks?.cron; - if (!entry) throw new Error("plugin registers no cron hook"); - const handler = typeof entry === "function" ? entry : entry.handler; - await handler({ name, scheduledAt: "2026-09-24T03:00:00.000Z" }, ctx); -} - -describe("collection discovery", () => { - it("walks the published entries of every collection without any configuration", async () => { - const { ctx, listed } = fakeContext({ posts: ["p1", "p2"], pages: ["about"] }); - - await tick(ctx, "sweep"); - await tick(ctx, "sweep-next-a"); - - expect(listed).toEqual([ - { collection: "posts", status: "published" }, - { collection: "pages", status: "published" }, - ]); - }); -}); diff --git a/packages/content-link-check/tests/extract.test.ts b/packages/content-link-check/tests/extract.test.ts deleted file mode 100644 index 9c1e62b..0000000 --- a/packages/content-link-check/tests/extract.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { classify, extractLinks, isRelativePath, toRequestUrl } from "../src/extract.js"; - -const SITE = "https://example.com"; - -describe("extractLinks", () => { - it("finds absolute URLs anywhere in the data", () => { - const links = extractLinks({ - title: "See https://external.test/docs for details", - meta: { nested: { deep: "http://other.test/page" } }, - }); - expect(links.map((l) => l.url)).toEqual(["https://external.test/docs", "http://other.test/page"]); - }); - - it("strips sentence punctuation from a URL in prose", () => { - const links = extractLinks({ title: "See https://external.test/docs, then stop." }); - expect(links.map((l) => l.url)).toEqual(["https://external.test/docs"]); - }); - - it("keeps a trailing slash or query that belongs to the URL", () => { - const links = extractLinks({ href: "https://external.test/docs/?q=1" }); - expect(links.map((l) => l.url)).toEqual(["https://external.test/docs/?q=1"]); - }); - - it("pulls hrefs out of portable text markDefs", () => { - const links = extractLinks({ - body: [ - { - _type: "block", - markDefs: [{ _key: "a1", _type: "link", href: "https://target.test/x" }], - children: [{ _type: "span", text: "click", marks: ["a1"] }], - }, - ], - }); - expect(links).toEqual([{ url: "https://target.test/x", path: "body[0].markDefs[0].href" }]); - }); - - it("takes relative paths only under link-ish keys", () => { - const links = extractLinks({ - href: "/about", - prose: "/not/a/link, just text with a slash", - }); - expect(links.map((l) => l.url)).toEqual(["/about"]); - }); - - it("takes relative paths from custom fields named after a link", () => { - // Field names from a real site whose internal links all went unseen. - const links = extractLinks({ - cta_url: "/contact", - hero_cta_url: "/en/contact", - services_items: [{ link_label: "Mehr", link_href: "/wordpress-zu-emdash" }], - ctaHref: "/about", - curl: "/not/a/link", - blurl: "/not/a/link/either", - }); - expect(links.map((l) => l.url)).toEqual(["/contact", "/en/contact", "/wordpress-zu-emdash", "/about"]); - }); - - it("deduplicates by url and keeps the first path", () => { - const links = extractLinks({ - a: { href: "/same" }, - b: { href: "/same" }, - }); - expect(links).toEqual([{ url: "/same", path: "a.href" }]); - }); - - it("ignores empty strings and respects the depth cap", () => { - expect(extractLinks({ href: " " })).toEqual([]); - const deep = { l1: { l2: { l3: { href: "https://too.deep/x" } } } }; - expect(extractLinks(deep, 2)).toEqual([]); - }); -}); - -describe("classify", () => { - it("treats same-origin absolute URLs as internal", () => { - expect(classify("https://example.com/a", SITE)).toBe("internal"); - expect(classify("/a", SITE)).toBe("internal"); - expect(classify("https://elsewhere.test/a", SITE)).toBe("external"); - }); - - it("does not crash on junk", () => { - expect(classify("not a url", SITE)).toBe("external"); - }); -}); - -describe("isRelativePath", () => { - it("rejects protocol-relative and bare slash", () => { - expect(isRelativePath("/about")).toBe(true); - expect(isRelativePath("//cdn.test/x")).toBe(false); - expect(isRelativePath("/")).toBe(false); - }); -}); - -describe("toRequestUrl", () => { - it("resolves relative paths against the site", () => { - expect(toRequestUrl("/about", SITE)).toBe("https://example.com/about"); - }); - - it("returns null for unparseable input", () => { - expect(toRequestUrl("http://[bad", SITE)).toBeNull(); - }); -}); diff --git a/packages/content-link-check/tests/full-sweep.test.ts b/packages/content-link-check/tests/full-sweep.test.ts deleted file mode 100644 index 2abc86f..0000000 --- a/packages/content-link-check/tests/full-sweep.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; -import { afterEach, describe, expect, it } from "vitest"; - -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; - -import type { SweepState } from "../src/state.js"; -import type { CheckRow } from "../src/store.js"; - -/** - * One scheduled run does one bounded slice of work: plan a page of entries, - * or check a batch of them. A nightly schedule that ran one slice per night - * would take weeks to get through a site of any size, so a run that made - * progress has to hand over to a follow-up run. - * - * This goes through the real runtime and cron executor, because the trap is - * there: the executor deletes a finished one-shot task by id, so a follow-up - * scheduled under the running task's own name is silently thrown away. - */ - -const ENTRIES = 60; -/** Creating a few hundred entries outlasts vitest's default five seconds on a busy machine. */ -const FIXTURE_HEAVY_MS = 30_000; -let host: PluginRuntimeTestHost | undefined; - -afterEach(async () => { - await host?.dispose(); - host = undefined; -}); - -/** - * A time on the test clock, `daysAhead` days from today. Follow-up runs are - * timed by the plugin's clock, which is the real one, while the executor - * runs on the test clock, so a night has to lie ahead of the real clock or - * no follow-up is ever due. - */ -function night(daysAhead: number, time: string): string { - const day = new Date(Date.now() + daysAhead * 86_400_000).toISOString().slice(0, 10); - return `${day}T${time}.000Z`; -} - -/** Run the cron executor until nothing is due, as successive Cron Trigger firings would. */ -async function drain(runtime: PluginRuntimeTestHost, limit: number): Promise { - let runs = 0; - for (; runs < limit; runs++) { - const { processed } = await runtime.scheduled.run(); - if (processed === 0) break; - } - return runs; -} - -/** A site with one collection whose string fields are the given link fields. */ -async function site(fields: string[]): Promise { - const runtime = await createPluginRuntimeTestHost(); - await runtime.fixtures.site({ url: "https://example.test" }); - await runtime.fixtures.collection({ - slug: "posts", - label: "Posts", - fields: fields.map((slug) => ({ slug, label: slug, type: "string" as const })), - }); - return runtime; -} - -function post(runtime: PluginRuntimeTestHost, slug: string, data: Record, status = "published") { - return runtime.fixtures.content("posts", { slug, data, status }); -} - -/** More runs than any sweep in this file needs, so a chain that never ends shows as reaching it. */ -const MAX_RUNS = 200; - -/** Activate the plugin and run the first nightly sweep to its end. */ -async function firstNight(runtime: PluginRuntimeTestHost): Promise { - runtime.scheduled.setTime(night(1, "02:59:00")); - await runtime.actions.plugin.activate(); - runtime.scheduled.setTime(night(1, "03:00:05")); - return drain(runtime, MAX_RUNS); -} - -async function nextNight(runtime: PluginRuntimeTestHost, daysAhead: number): Promise { - runtime.scheduled.setTime(night(daysAhead, "03:00:05")); - await drain(runtime, MAX_RUNS); -} - -async function checks(runtime: PluginRuntimeTestHost): Promise> { - return new Map((await runtime.inspect.storage.list("checks")).map(({ id, data }) => [id, data])); -} - -function requestsTo(runtime: PluginRuntimeTestHost, host: string): string[] { - return runtime.http - .requests() - .filter((request) => new URL(request.url).host === host) - .map((request) => `${request.method} ${request.url}`); -} - -const ok = () => new Response(null, { status: 200 }); - -describe("a scheduled sweep", () => { - it("checks every entry on the night it starts", async () => { - host = await site(["cta_url"]); - for (let i = 0; i < ENTRIES; i++) { - await post(host, `post-${i}`, { cta_url: `/page-${i}` }); - await host.http.respond(`https://example.test/page-${i}`, ok()); - } - - // The chain has to end by itself once the sweep is done. - expect(await firstNight(host)).toBeLessThan(MAX_RUNS); - - const stored = await checks(host); - expect(stored.size).toBe(ENTRIES); - expect([...stored.values()].every((check) => check.state === "ok")).toBe(true); - }); - - it("checks a link shared by several entries once", async () => { - host = await site(["source_url"]); - for (const slug of ["a", "b", "c"]) await post(host, slug, { source_url: "https://shared.test/page#part" }); - await host.http.respond("https://shared.test/page", ok()); - - await firstNight(host); - - expect(requestsTo(host, "shared.test")).toEqual(["HEAD https://shared.test/page"]); - expect((await checks(host)).get("https://shared.test/page")).toMatchObject({ state: "ok", placeCount: 3 }); - }); - - it("skips drafts, and forgets an entry's findings once it is unpublished", async () => { - host = await site(["cta_url", "source_url"]); - const live = await post(host, "live", { cta_url: "/old-page" }); - await post(host, "draft", { source_url: "https://draft.test/x" }, "draft"); - await host.http.respond( - "https://example.test/old-page", - new Response(null, { status: 301, headers: { location: "/new-page" } }), - ); - - await firstNight(host); - expect((await checks(host)).get("https://example.test/old-page")).toMatchObject({ finding: "redirect" }); - expect(requestsTo(host, "draft.test")).toEqual([]); - - await host.actions.content.unpublish("posts", live.id); - await nextNight(host, 2); - - expect(await checks(host)).toEqual(new Map()); - }); - - it("reports a finding however many working links were checked before it", async () => { - host = await site(["cta_url"]); - for (let i = 0; i < 101; i++) { - await post(host, `fine-${i}`, { cta_url: `/fine-${i}` }); - await host.http.respond(`https://example.test/fine-${i}`, ok()); - } - await post(host, "stale", { cta_url: "/old-page" }); - await host.http.respond( - "https://example.test/old-page", - new Response(null, { status: 301, headers: { location: "/new-page" } }), - ); - - await firstNight(host); - - expect((await reportRows(host)).map((row) => row.url)).toEqual(["/old-page"]); - }, FIXTURE_HEAVY_MS); - - it("keeps the report within emdash's limits when one link repeats across many entries", async () => { - host = await site(["cta_url"]); - for (let i = 0; i < 250; i++) await post(host, `post-${i}`, { cta_url: "/old-page" }); - await host.http.respond( - "https://example.test/old-page", - new Response(null, { status: 301, headers: { location: "/new-page" } }), - ); - - await firstNight(host); - - const rows = await reportRows(host); - expect(rows).toHaveLength(1); - expect(rows[0]?.entry).toMatch(/ \+249 more$/); - }, FIXTURE_HEAVY_MS); - - it("clears the findings of an entry unpublished while the plugin was switched off", async () => { - host = await site(["cta_url"]); - const live = await post(host, "live", { cta_url: "/old-page" }); - await host.http.respond( - "https://example.test/old-page", - new Response(null, { status: 301, headers: { location: "/new-page" } }), - ); - await firstNight(host); - expect((await checks(host)).size).toBe(1); - - await host.actions.plugin.deactivate(); - await host.actions.content.unpublish("posts", live.id); - await host.actions.plugin.activate(); - await nextNight(host, 2); - - expect(await checks(host)).toEqual(new Map()); - }); -}); - -describe("a requested sweep", () => { - it("starts afresh when requested while another sweep is under way", async () => { - host = await site(["cta_url"]); - for (let i = 0; i < 40; i++) { - await post(host, `post-${i}`, { cta_url: `/page-${i}` }); - // Once for the interrupted sweep, once for the one that replaces it. - await host.http.respond(`https://example.test/page-${i}`, ok()); - await host.http.respond(`https://example.test/page-${i}`, ok()); - } - host.scheduled.setTime(night(1, "02:59:00")); - await host.actions.plugin.activate(); - host.scheduled.setTime(night(1, "03:00:05")); - for (let i = 0; i < 4; i++) await host.scheduled.run(); - const interrupted = await host.inspect.kv.get("state"); - expect(interrupted?.phase).not.toBe("done"); - - await host.actions.routes.request("admin", { - method: "POST", - headers: { "x-emdash-request": "1" }, - body: { type: "block_action", action_id: "sweep_now" }, - user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }, - }); - await drain(host, MAX_RUNS); - - const finished = await host.inspect.kv.get("state"); - expect(finished?.phase).toBe("done"); - expect(finished?.startedAt && interrupted?.startedAt && finished.startedAt > interrupted.startedAt).toBe(true); - }); -}); - -describe("two nightly sweeps", () => { - it("follow an outbound redirect, report an internal one, and leave a rate-limited host alone", async () => { - host = await site(["cta_url", "source_url", "press_url", "docs_url"]); - await post(host, "post", { - cta_url: "/old-page", - source_url: "https://busy.test/report", - press_url: "https://moved.test/", - docs_url: "https://busy.test/docs", - }); - - const internalHop = () => new Response(null, { status: 301, headers: { location: "/new-page" } }); - const outboundHop = () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }); - // Longer than both nights, on the test clock and the real one alike. - const rateLimited = () => new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }); - - // Every hop needs an answer: an unanswered request hangs the sandboxed - // run instead of failing it. - await host.http.respond("https://example.test/old-page", internalHop()); - await host.http.respond("https://moved.test/", outboundHop()); - await host.http.respond("https://new.test/", ok()); - await host.http.respond("https://busy.test/report", rateLimited()); - await host.http.respond("https://busy.test/docs", rateLimited()); - await firstNight(host); - - await host.http.respond("https://example.test/old-page", internalHop()); - await host.http.respond("https://moved.test/", outboundHop()); - await host.http.respond("https://new.test/", ok()); - await nextNight(host, 2); - - const stored = await checks(host); - expect(stored.get("https://example.test/old-page")).toMatchObject({ state: "redirect", reported: true }); - expect(stored.get("https://moved.test/")).toMatchObject({ - state: "ok", - finalUrl: "https://new.test/", - reported: false, - }); - // Whichever busy.test link comes first is asked; the host's 429 spares the other. - const asked = requestsTo(host, "busy.test"); - expect(asked).toHaveLength(1); - const askedUrl = asked[0]?.split(" ")[1] ?? ""; - expect(stored.get(askedUrl)).toMatchObject({ reason: "rate-limited", reported: false }); - }); -}); - -/** The report table's rows, as the admin would receive them after emdash's Block Kit validation. */ -async function reportRows(runtime: PluginRuntimeTestHost): Promise>> { - const response = await runtime.actions.routes.request("admin", { - method: "POST", - headers: { "x-emdash-request": "1" }, - body: { type: "page_load", page: "/report" }, - user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }, - }); - const body = (await response.json()) as { data?: { blocks?: unknown }; error?: unknown }; - if (!Array.isArray(body.data?.blocks)) throw new Error(`report rejected: ${JSON.stringify(body.error)}`); - const table = (body.data.blocks as Array>).find((block) => block.type === "table"); - return (table as { rows?: Array> } | undefined)?.rows ?? []; -} diff --git a/packages/content-link-check/tests/permissions.test.ts b/packages/content-link-check/tests/permissions.test.ts deleted file mode 100644 index 34d2ad8..0000000 --- a/packages/content-link-check/tests/permissions.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * The widget exists for editors, so the route has to say so: an `admin` - * route with no declared permission dispatches at `plugins:manage`, which is - * administrators only, and every editor gets a permission error inside the - * widget card instead of the report. - * - * One route carries one permission, and this one also serves the settings - * form, so the form is held to administrators in the handler. - */ - -function fakeContext() { - const writes: Array<[string, unknown]> = []; - const scheduled: string[] = []; - const ctx = { - kv: { - async get() { - return null; - }, - async set(key: string, value: unknown) { - writes.push([key, value]); - }, - }, - cron: { - async schedule(name: string) { - scheduled.push(name); - }, - }, - log: { debug() {}, info() {}, warn() {}, error() {} }, - } as unknown as PluginContext; - return { ctx, writes, scheduled }; -} - -function adminHandler() { - const route = plugin.routes?.admin; - if (!route) throw new Error("plugin registers no admin route"); - return typeof route === "function" ? route : route.handler; -} - -async function saveSettingsAs(role: number | undefined) { - const { ctx, writes, scheduled } = fakeContext(); - const result = (await adminHandler()( - { - input: { type: "form_submit", action_id: "save_settings", values: { schedule: "0 5 * * *" } }, - request: { url: "https://example.test", method: "POST", headers: {} }, - ...(role === undefined ? {} : { user: { id: "u1", email: "u@example.test", name: null, role } }), - } as never, - ctx, - )) as { toast?: { message: string; type: string } }; - return { result, writes, scheduled }; -} - -describe("the admin route's permission", () => { - it("declares plugins:read so editors can open it", () => { - const route = plugin.routes?.admin; - expect(typeof route === "object" && route.permission).toBe("plugins:read"); - }); -}); - -describe("saving settings", () => { - it("is refused for an editor, with a reason", async () => { - const { result, writes, scheduled } = await saveSettingsAs(ROLE.EDITOR); - expect(writes).toHaveLength(0); - expect(scheduled).toHaveLength(0); - expect(result.toast?.type).toBe("error"); - expect(result.toast?.message).toMatch(/administrator/i); - }); - - it("is refused when there is no user at all", async () => { - const { result, writes } = await saveSettingsAs(undefined); - expect(writes).toHaveLength(0); - expect(result.toast?.type).toBe("error"); - }); - - it("goes through for an administrator", async () => { - const { result, writes, scheduled } = await saveSettingsAs(ROLE.ADMIN); - expect(writes.length).toBeGreaterThan(0); - // The cron is re-registered so a changed expression takes effect. - expect(scheduled).toContain("sweep"); - expect(result.toast).toBeUndefined(); - }); - - it("stores nothing when emdash refuses the schedule", async () => { - const { ctx, writes } = fakeContext(); - const refusing = { - ...ctx, - cron: { - async schedule() { - throw new Error("Invalid cron expression"); - }, - }, - } as unknown as PluginContext; - const result = (await adminHandler()( - { - input: { type: "form_submit", action_id: "save_settings", values: { schedule: "every night" } }, - request: { url: "https://example.test", method: "POST", headers: {} }, - user: { id: "u1", email: "u@example.test", name: null, role: ROLE.ADMIN }, - } as never, - refusing, - )) as { toast?: { message: string; type: string } }; - expect(writes).toHaveLength(0); - expect(result.toast?.type).toBe("error"); - }); -}); diff --git a/packages/content-link-check/tests/plugin.test.ts b/packages/content-link-check/tests/plugin.test.ts deleted file mode 100644 index b1a6972..0000000 --- a/packages/content-link-check/tests/plugin.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import plugin from "../src/plugin.js"; - -/** - * Surface test. The build probes the default export for its hook and - * route keys, so a rename that the manifest does not know about is a - * silent behaviour loss — this catches it. - */ -describe("plugin surface", () => { - it("registers the hooks the sweep needs", () => { - expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual([ - "content:afterPublish", - "content:afterSave", - "cron", - "plugin:activate", - ]); - }); - - it("registers the admin and status routes", () => { - expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "status"]); - }); -}); diff --git a/packages/content-link-check/tests/report.test.ts b/packages/content-link-check/tests/report.test.ts deleted file mode 100644 index 95518cb..0000000 --- a/packages/content-link-check/tests/report.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import { buildReportPage, buildSettingsPage } from "../src/report.js"; -import { settingsFrom } from "../src/settings.js"; -import type { CheckRow, Place } from "../src/store.js"; - -function place(entrySlug: string | null, locale: string): Place { - return { - url: "/privacy-policy", - path: "content[45].markDefs[0].href", - collection: "pages", - entryId: `01M2RABGX6C6AYNDFBV542M7D5-${locale}`, - entrySlug, - locale, - }; -} - -function redirect(target: string, places: Place[], placeCount = places.length): CheckRow { - return { - target, - scope: "internal", - state: "redirect", - reason: null, - status: 301, - location: "/datenschutz", - finalUrl: null, - error: null, - consecutiveFailures: 0, - firstFailedAt: null, - reported: true, - finding: "redirect", - checkedAt: "2026-09-24T00:00:00.000Z", - firstSeenAt: "2026-09-24T00:00:00.000Z", - seenAt: "2026-09-24T00:00:00.000Z", - lastPage: "", - places, - placeCount, - }; -} - -/** Storage that answers every query with all its rows; the where clauses are emdash's to test. */ -function fakeContext(checks: CheckRow[]) { - return { - storage: { - checks: { - count: async () => checks.length, - query: async () => ({ items: checks.map((data) => ({ id: data.target, data })), cursor: null }), - }, - }, - } as unknown as PluginContext; -} - -function table(blocks: Array>) { - return blocks.find((block) => block.type === "table") as - | { columns: Array<{ key: string }>; rows: Array> } - | undefined; -} - -describe("the link report", () => { - it("names each finding's entry by its slug, and its language on a multilingual site", async () => { - const { blocks } = await buildReportPage( - fakeContext([ - redirect("https://example.test/datenschutz-alt", [place("datenschutz", "de")]), - redirect("https://example.test/privacy-old", [place("privacy", "en")]), - ]), - ); - const rows = table(blocks)?.rows ?? []; - expect(rows.map((row) => [row.entry, row.locale]).sort()).toEqual([ - ["pages/datenschutz", "de"], - ["pages/privacy", "en"], - ]); - }); - - it("lists a link once, however many entries it appears in", async () => { - const shared = redirect("https://example.test/privacy-policy", [place("datenschutz", "de"), place("privacy", "de")], 12); - const { blocks } = await buildReportPage(fakeContext([shared])); - expect(table(blocks)?.rows.map((row) => row.entry)).toEqual(["pages/datenschutz +11 more"]); - }); - - it("falls back to the id for an entry without a slug", async () => { - const { blocks } = await buildReportPage( - fakeContext([redirect("https://example.test/privacy-policy", [place(null, "de")])]), - ); - expect(table(blocks)?.rows[0]?.entry).toBe("pages/01M2RABGX6C6AYNDFBV542M7D5-de"); - expect(table(blocks)?.columns.map((column) => column.key)).not.toContain("locale"); - }); -}); - -describe("the settings form", () => { - it("shows the saved values, not blank fields", async () => { - // Block Kit inputs render `initial_value`. With anything else the outbound - // toggle showed as off while the plugin was still checking outbound links. - const { blocks } = buildSettingsPage(settingsFrom({ batchSize: 10 })); - const form = blocks.find((block) => block.type === "form") as { - fields: Array<{ action_id: string; initial_value?: unknown }>; - }; - const value = (id: string) => form.fields.find((field) => field.action_id === id)?.initial_value; - expect(value("batchSize")).toBe(10); - expect(value("checkExternal")).toBe(true); - expect(value("schedule")).toBe("0 3 * * *"); - }); -}); diff --git a/packages/content-link-check/tests/schedule.test.ts b/packages/content-link-check/tests/schedule.test.ts deleted file mode 100644 index 847e3f6..0000000 --- a/packages/content-link-check/tests/schedule.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { ROLE } from "@eisbachcode/emdash-plugin-shared"; -import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -/** - * The plugin remembers, per isolate, that it has seen its sweep in place. - * Each test loads a fresh copy so that memory starts empty. - */ -let plugin: SandboxedPlugin; - -beforeEach(async () => { - vi.resetModules(); - plugin = (await import("../src/plugin.js")).default; -}); - -interface FakeCron { - list(): Promise>; - schedule(name: string, opts: { schedule: string }): Promise; -} - -function fakeContext(cron: FakeCron, kv: Record = {}) { - return { - cron, - kv: { - get: async (key: string) => kv[key] ?? null, - list: async () => Object.entries(kv).map(([key, value]) => ({ key, value })), - }, - storage: { - checks: { count: async () => 0, query: async () => ({ items: [], cursor: null }) }, - }, - log: { debug() {}, info() {}, warn() {}, error() {} }, - } as unknown as PluginContext; -} - -async function loadWidget(ctx: PluginContext) { - const route = plugin.routes?.admin; - if (!route || typeof route === "function") throw new Error("plugin registers no admin route object"); - return (await route.handler( - { - input: { type: "page_load", page: "widget:summary" }, - request: { url: "https://example.test", method: "POST", headers: {} }, - user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN }, - } as never, - ctx, - )) as { blocks?: unknown[] }; -} - -describe("the sweep schedule", () => { - it("is put back on the saved schedule when the registered task has drifted from it", async () => { - const scheduled: Array<[string, string]> = []; - const cron: FakeCron = { - list: async () => [{ name: "sweep", schedule: "0 3 * * *" }], - schedule: async (name, opts) => void scheduled.push([name, opts.schedule]), - }; - await loadWidget(fakeContext(cron, { settings: { schedule: "0 5 * * *" } })); - expect(scheduled).toEqual([["sweep", "0 5 * * *"]]); - }); - - it("is left alone when the registered task matches the saved schedule", async () => { - const scheduled: string[] = []; - const cron: FakeCron = { - list: async () => [{ name: "sweep", schedule: "0 3 * * *" }], - schedule: async (name) => void scheduled.push(name), - }; - await loadWidget(fakeContext(cron)); - expect(scheduled).toEqual([]); - }); - - it("never costs the admin the page when it cannot be checked", async () => { - const unavailable = async () => { - throw new Error("cron unavailable"); - }; - const result = await loadWidget(fakeContext({ list: unavailable, schedule: unavailable })); - expect(result.blocks?.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/content-link-check/tests/settings.test.ts b/packages/content-link-check/tests/settings.test.ts deleted file mode 100644 index 8dd5d1a..0000000 --- a/packages/content-link-check/tests/settings.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_SETTINGS, mergeSettings, readSettings } from "../src/settings.js"; - -describe("readSettings", () => { - it("returns defaults on an empty store", async () => { - expect(await readSettings(fakeKv({}))).toEqual(DEFAULT_SETTINGS); - }); - - it("reads stored values", async () => { - const settings = await readSettings(fakeKv({ settings: { batchSize: 10, checkExternal: false } })); - expect(settings).toMatchObject({ - batchSize: 10, - checkExternal: false, - schedule: DEFAULT_SETTINGS.schedule, - }); - }); -}); - -describe("mergeSettings", () => { - it("clamps on the way in and ignores unknown keys", () => { - const merged = mergeSettings(DEFAULT_SETTINGS, { batchSize: 9999, nonsense: true }); - expect(merged.batchSize).toBe(100); - expect(merged).not.toHaveProperty("nonsense"); - }); - - it("keeps what the form did not send", () => { - const current = { ...DEFAULT_SETTINGS, schedule: "0 5 * * *" }; - expect(mergeSettings(current, { checkExternal: false })).toMatchObject({ - schedule: "0 5 * * *", - checkExternal: false, - }); - }); -}); - -function fakeKv(store: Record) { - return { - kv: { - get: async (key: string) => store[key] ?? null, - list: async () => Object.entries(store).map(([key, value]) => ({ key, value })), - }, - } as unknown as import("emdash/plugin").PluginContext; -} diff --git a/packages/content-link-check/tsconfig.json b/packages/content-link-check/tsconfig.json deleted file mode 100644 index 8268866..0000000 --- a/packages/content-link-check/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "preserve", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "verbatimModuleSyntax": true, - "skipLibCheck": true, - "types": [] - }, - "include": [ - "src/**/*", - "tests/**/*", - "vitest.config.ts" - ], - "exclude": [ - "node_modules" - ] -} diff --git a/packages/content-link-check/vitest.config.ts b/packages/content-link-check/vitest.config.ts deleted file mode 100644 index 856fcc5..0000000 --- a/packages/content-link-check/vitest.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - plugins: [emdashPluginTest()], - test: { - // Most tests run the plugin inside workerd through the test host, which - // can pass vitest's 5 s default on a laptop and miss it on shared CI - // runners. The same limits as upstream's own plugin-test suite. - testTimeout: 30_000, - hookTimeout: 30_000, - // plugin-test 0.2 builds the plugin into this package's dist/ once - // per test file and reads dist/manifest.json back. Files run in - // parallel rewrite that file while others read it, and a reader that - // gets it half-written fails the whole file with "Unexpected end of - // JSON input". - fileParallelism: false, - }, -}); diff --git a/packages/shared/package.json b/packages/shared/package.json deleted file mode 100644 index 75ebc52..0000000 --- a/packages/shared/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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.39.0 <1.0.0" - }, - "devDependencies": { - "emdash": ">=0.39.0 <1.0.0", - "typescript": "^5.9.0", - "vitest": "^4.1.0" - } -} diff --git a/packages/shared/src/chain.ts b/packages/shared/src/chain.ts deleted file mode 100644 index 57e9bcd..0000000 --- a/packages/shared/src/chain.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Carrying a sweep across cron runs until it is finished. - * - * One run does one bounded slice of work, because a sandboxed plugin gets - * 10 subrequests per invocation. A sweep started by a nightly schedule would - * otherwise advance one slice per night. So a run that made progress - * schedules a one-shot follow-up, and the chain ends when the sweep does. - * - * The follow-up alternates between two task names. emdash deletes a - * finished one-shot task by id, and `ctx.cron.schedule()` upserts on the - * task name, so a follow-up scheduled under the running task's own name - * updates the row that is about to be deleted, and the chain ends silently. - */ - -import type { PluginContext } from "emdash/plugin"; - -export interface SweepChain { - /** Two one-shot task names the follow-ups alternate between. */ - next: readonly [string, string]; - /** Follow-ups after which a chain stops and waits for the next scheduled run. */ - maxSteps: number; -} - -export interface ChainEvent { - name: string; - data?: Record; -} - -export function isFollowUp(chain: SweepChain, name: string): boolean { - return chain.next.includes(name); -} - -/** - * Whether a follow-up is already waiting. A scheduled run or a "now" button - * that started work anyway would run a second chain alongside the first. - */ -export async function followUpPending(ctx: PluginContext, chain: SweepChain): Promise { - const tasks = (await ctx.cron?.list()) ?? []; - return tasks.some((task) => isFollowUp(chain, task.name)); -} - -/** Schedule the next run of the chain. Returns false once the chain has reached `maxSteps`. */ -export async function scheduleFollowUp(ctx: PluginContext, chain: SweepChain, event: ChainEvent): Promise { - const step = typeof event.data?.step === "number" ? event.data.step : 0; - if (step >= chain.maxSteps) return false; - const name = event.name === chain.next[0] ? chain.next[1] : chain.next[0]; - await ctx.cron?.schedule(name, { - schedule: new Date(Date.now() + 1000).toISOString(), - data: { step: step + 1 }, - }); - return true; -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts deleted file mode 100644 index db9d233..0000000 --- a/packages/shared/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - followUpPending, - isFollowUp, - scheduleFollowUp, - type ChainEvent, - type SweepChain, -} from "./chain.js"; -export { clampNumber, parseCollections } from "./values.js"; -export { - listCollections, - nextCollectionPage, - resetCursors, - type CollectionPage, - type PluginCollectionInfo, - 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 deleted file mode 100644 index 6399ab0..0000000 --- a/packages/shared/src/roles.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4457f33..0000000 --- a/packages/shared/src/sweep.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Cursor-driven collection walk. - * - * Both plugins sweep every 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; -} - -/** A collection as `schema:read` describes it. Derived for the same reason as `PluginContentItem`. */ -export type PluginCollectionInfo = Awaited< - ReturnType["listCollections"]> ->[number]; - -/** Every collection on the site, or none when `schema:read` was not granted. */ -export async function listCollections(ctx: PluginContext): Promise { - return ctx.schema ? ctx.schema.listCollections() : []; -} - -/** 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/tests/chain.test.ts b/packages/shared/tests/chain.test.ts deleted file mode 100644 index 3dcd5c0..0000000 --- a/packages/shared/tests/chain.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { PluginContext } from "emdash/plugin"; -import { describe, expect, it } from "vitest"; - -import { followUpPending, scheduleFollowUp, type SweepChain } from "../src/chain.js"; - -const CHAIN: SweepChain = { next: ["sweep-next-a", "sweep-next-b"], maxSteps: 3 }; - -function fakeCron(pending: string[] = []) { - const scheduled: Array<{ name: string; data?: Record }> = []; - const ctx = { - cron: { - async schedule(name: string, opts: { schedule: string; data?: Record }) { - scheduled.push({ name, data: opts.data }); - }, - async list() { - return pending.map((name) => ({ name, schedule: "2026-09-25T03:00:00.000Z" })); - }, - }, - } as unknown as PluginContext; - return { ctx, scheduled }; -} - -describe("scheduleFollowUp", () => { - it("never reschedules the task that is running", async () => { - const { ctx, scheduled } = fakeCron(); - await scheduleFollowUp(ctx, CHAIN, { name: "sweep" }); - await scheduleFollowUp(ctx, CHAIN, { name: "sweep-next-a", data: { step: 1 } }); - await scheduleFollowUp(ctx, CHAIN, { name: "sweep-next-b", data: { step: 2 } }); - expect(scheduled.map((task) => task.name)).toEqual(["sweep-next-a", "sweep-next-b", "sweep-next-a"]); - }); - - it("stops a chain that would otherwise run forever", async () => { - const { ctx, scheduled } = fakeCron(); - let event: { name: string; data?: Record } = { name: "sweep" }; - for (let run = 0; run < 10; run++) { - if (!(await scheduleFollowUp(ctx, CHAIN, event))) break; - event = scheduled[scheduled.length - 1]!; - } - expect(scheduled).toHaveLength(CHAIN.maxSteps); - }); -}); - -describe("followUpPending", () => { - it("sees a waiting follow-up, so a new run does not start a second chain", async () => { - expect(await followUpPending(fakeCron(["sweep", "sweep-next-b"]).ctx, CHAIN)).toBe(true); - expect(await followUpPending(fakeCron(["sweep", "sweep-now"]).ctx, CHAIN)).toBe(false); - }); -}); diff --git a/packages/shared/tests/roles.test.ts b/packages/shared/tests/roles.test.ts deleted file mode 100644 index 12610df..0000000 --- a/packages/shared/tests/roles.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 7b43ff7..0000000 --- a/packages/shared/tests/sweep.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -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/tsconfig.json b/packages/shared/tsconfig.json deleted file mode 100644 index 0bca85d..0000000 --- a/packages/shared/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "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 index 90ace02..ec5394b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,6 @@ importers: '@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) @@ -28,7 +22,7 @@ importers: version: 0.2.0(f863f6b7fe5271f41d62dece9033c2f3) 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) + version: 0.40.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.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3) @@ -42,63 +36,6 @@ importers: specifier: 4.5.4 version: 4.5.4 - packages/content-freshness: - 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.6.5) - '@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.11.3)(@emnapi/runtime@1.11.3) - '@emdash-cms/plugin-test': - specifier: 0.2.3 - version: 0.2.3(f791968eac255bb6d5c696c81a641535) - emdash: - specifier: '>=0.39.0 <1.0.0' - version: 0.40.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/content-link-check: - devDependencies: - '@eisbachcode/emdash-plugin-shared': - specifier: workspace:* - version: link:../shared - '@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.11.3)(@emnapi/runtime@1.11.3) - '@emdash-cms/plugin-test': - specifier: 0.2.3 - version: 0.2.3(f64bdd591a184e04878ebed7d065ed68) - emdash: - specifier: '>=0.39.0 <1.0.0' - version: 0.40.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/shared: - devDependencies: - emdash: - specifier: '>=0.39.0 <1.0.0' - version: 0.40.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': @@ -610,12 +547,6 @@ packages: 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/admin@0.40.1': resolution: {integrity: sha512-+3ZzHzp0xRO3XtxSzRdIpAI1vPRBt9axiEzNfSKHOGwEm3783vwfwKENJAiJ8OYMEpVk4xdZLgytUQIxgTq1OA==} peerDependencies: @@ -631,15 +562,6 @@ packages: 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/auth@0.40.1': resolution: {integrity: sha512-ie6/7udv7bKBrBfIRSyfdT8vX0J7wNiq+Xcq/1A03ZR0rWtnYCEaPQzENlDv8jnZ/I7ypbCo6Q2ukuiKTZTkfg==} peerDependencies: @@ -655,12 +577,6 @@ packages: 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/blocks@0.40.1': resolution: {integrity: sha512-jtSCyLlqSPsd47r5mO7cS4vGCoJgU7doYBqGP4BCZ8Zd6aPuNSix7yKNZDnRJ/1k+CC3jgA1XsiTUJdnbopdLA==} peerDependencies: @@ -689,34 +605,9 @@ packages: react: optional: true - '@emdash-cms/cloudflare@0.40.1': - resolution: {integrity: sha512-cA7dTRw0X0H89To8D6VWBafdHBCsFxmFcTqhqgx7znH1Jj6XPE6V2JNdK31vonZkHBBf8BGvqQAfT+y31k/KTw==} - 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/gutenberg-to-portable-text@0.40.1': resolution: {integrity: sha512-95nx2h3b7xf4+3CgzqNg3nIqd+CQLc8MasXO6nJgglBtI7obfqSuaj0gsBfhVyymbTG90rCc9nJNtFkOaThm5g==} @@ -730,12 +621,6 @@ packages: vite: ^8.0.0 vitest: ^4.1.0 - '@emdash-cms/plugin-test@0.2.3': - resolution: {integrity: sha512-DY+moUl3DZUu1s1rM2BL9kSrsAr+KpS9jvP/1De57qhK1y8kH3OAeexYGh7vfoyNWG8H357P50k7sR8BI+Jvmg==} - peerDependencies: - vite: ^8.0.0 - vitest: ^4.1.0 - '@emdash-cms/plugin-types@0.4.0': resolution: {integrity: sha512-HqYiU8xL/uTalcPHcVI5K/n3Z1joa1WpvI0/itG+RmJwMWGHLv5ATmo9OeChczjfb31orJ4wKfFQIEXbNxrsKA==} @@ -2595,20 +2480,6 @@ packages: '@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 - emdash@0.40.1: resolution: {integrity: sha512-26xb74bpyQR0BTt0NWRuzWbAnQ9XmmKnRX+W/z2Q+l6e0Fh6Y6uV/rFa7S7yEGvo8Wj7HhbKFoTKYLpFOkdgfQ==} engines: {node: '>=22.16'} @@ -4113,9 +3984,6 @@ packages: 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==} @@ -4421,8 +4289,8 @@ snapshots: '@babel/generator@8.0.0-rc.2': dependencies: - '@babel/parser': 8.0.0-rc.2 - '@babel/types': 8.0.0-rc.2 + '@babel/parser': 8.0.6 + '@babel/types': 8.0.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 '@types/jsesc': 2.5.1 @@ -4444,7 +4312,7 @@ snapshots: '@babel/parser@8.0.0-rc.2': dependencies: - '@babel/types': 8.0.0-rc.2 + '@babel/types': 8.0.6 '@babel/parser@8.0.6': dependencies: @@ -4460,7 +4328,7 @@ snapshots: '@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/helper-validator-identifier': 8.0.6 '@babel/types@8.0.6': dependencies: @@ -4667,28 +4535,6 @@ snapshots: - '@types/react' - date-fns - '@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.6.5)': - 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.6.5 - 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)': @@ -4854,85 +4700,6 @@ snapshots: - 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/admin@0.40.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@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/lexicons': 2.1.1 @@ -5022,17 +4789,6 @@ snapshots: 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/auth@0.40.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': dependencies: '@oslojs/crypto': 1.0.1 @@ -5060,38 +4816,6 @@ snapshots: - date-fns - zod - '@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.6.5)': - 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.6.5) - '@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/blocks@0.40.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) @@ -5108,22 +4832,6 @@ snapshots: - date-fns - zod - '@emdash-cms/blocks@0.40.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.6.5)': - 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.6.5) - '@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) @@ -5168,60 +4876,11 @@ snapshots: - typescript - utf-8-validate - '@emdash-cms/cloudflare@0.40.1(4393bffb26115eecd490f095bbbbcb1e)': - 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.40.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) - 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.6.5) - '@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/gutenberg-to-portable-text@0.40.1': dependencies: '@wordpress/block-serialization-default-parser': 5.56.0 @@ -5328,130 +4987,6 @@ snapshots: - wrangler - zod - '@emdash-cms/plugin-test@0.2.3(f64bdd591a184e04878ebed7d065ed68)': - 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.40.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.6.5) - '@emdash-cms/cloudflare': 0.40.1(4393bffb26115eecd490f095bbbbcb1e) - '@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.11.3)(@emnapi/runtime@1.11.3) - '@emdash-cms/plugin-types': 0.4.0 - emdash: 0.40.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) - 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-test@0.2.3(f791968eac255bb6d5c696c81a641535)': - 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.40.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/cloudflare': 0.40.1(4393bffb26115eecd490f095bbbbcb1e) - '@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.11.3)(@emnapi/runtime@1.11.3) - '@emdash-cms/plugin-types': 0.4.0 - emdash: 0.40.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) - 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 @@ -7177,93 +6712,6 @@ snapshots: - 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 - emdash@0.40.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) @@ -8253,7 +7701,7 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3): + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@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 @@ -8601,7 +8049,7 @@ snapshots: obug: 2.2.1 picomatch: 4.0.7 rolldown: 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@6.0.3) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17 @@ -8879,9 +8327,6 @@ snapshots: zod@4.5.4: {} - zod@4.6.5: - optional: true - zrender@6.1.0: dependencies: tslib: 2.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e001ad..fb1a29d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,3 @@ -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. diff --git a/scripts/compat-matrix.sh b/scripts/compat-matrix.sh index c1c5559..8a47e4b 100755 --- a/scripts/compat-matrix.sh +++ b/scripts/compat-matrix.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# Run every harness-backed plugin suite against a list of EmDash versions and -# print which combinations pass. +# Run the plugin's 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 @@ -13,10 +13,6 @@ # 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. -# # The reverse happened with EmDash 1.0: it moved the module the harness loads # (`emdash/plugin-test-runtime` became `emdash/internal/plugin-test-runtime`), # so no harness before plugin-test 0.2.6 can start a 1.0 host. A version @@ -47,11 +43,7 @@ if [ -n "$(git status --porcelain pnpm-workspace.yaml pnpm-lock.yaml)" ]; then 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; } +PKGS=(analytics) restore() { git checkout -- pnpm-workspace.yaml pnpm-lock.yaml 2>/dev/null || true @@ -83,7 +75,7 @@ run_suites() { return fi for p in "${PKGS[@]}"; do - if out=$(cd "packages/$p" && pnpm vitest run 2>&1); then + if out=$(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) diff --git a/packages/analytics/skills/creating-plugins/SKILL.md b/skills/creating-plugins/SKILL.md similarity index 100% rename from packages/analytics/skills/creating-plugins/SKILL.md rename to skills/creating-plugins/SKILL.md diff --git a/packages/analytics/src/astro/index.ts b/src/astro/index.ts similarity index 100% rename from packages/analytics/src/astro/index.ts rename to src/astro/index.ts diff --git a/packages/analytics/src/i18n.ts b/src/i18n.ts similarity index 100% rename from packages/analytics/src/i18n.ts rename to src/i18n.ts diff --git a/packages/analytics/src/index/bootstrap.ts b/src/index/bootstrap.ts similarity index 94% rename from packages/analytics/src/index/bootstrap.ts rename to src/index/bootstrap.ts index fb7cf3a..102347d 100644 --- a/packages/analytics/src/index/bootstrap.ts +++ b/src/index/bootstrap.ts @@ -31,13 +31,25 @@ * 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"; +/** + * 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. + */ +type PluginContentItem = Awaited< + ReturnType["list"]> +>["items"][number]; + /** * Bumped when a walk starts storing something earlier walks did not, or * stored wrongly. A site whose last completed walk has a lower version diff --git a/packages/analytics/src/index/entries.ts b/src/index/entries.ts similarity index 100% rename from packages/analytics/src/index/entries.ts rename to src/index/entries.ts diff --git a/packages/analytics/src/index/paths.ts b/src/index/paths.ts similarity index 100% rename from packages/analytics/src/index/paths.ts rename to src/index/paths.ts diff --git a/packages/analytics/src/plugin.ts b/src/plugin.ts similarity index 100% rename from packages/analytics/src/plugin.ts rename to src/plugin.ts diff --git a/packages/analytics/src/providers/cloudflare.ts b/src/providers/cloudflare.ts similarity index 100% rename from packages/analytics/src/providers/cloudflare.ts rename to src/providers/cloudflare.ts diff --git a/packages/analytics/src/providers/demo.ts b/src/providers/demo.ts similarity index 100% rename from packages/analytics/src/providers/demo.ts rename to src/providers/demo.ts diff --git a/packages/analytics/src/providers/types.ts b/src/providers/types.ts similarity index 100% rename from packages/analytics/src/providers/types.ts rename to src/providers/types.ts diff --git a/packages/analytics/src/settings.ts b/src/settings.ts similarity index 98% rename from packages/analytics/src/settings.ts rename to src/settings.ts index 8cfaed3..f349c45 100644 --- a/packages/analytics/src/settings.ts +++ b/src/settings.ts @@ -13,7 +13,7 @@ * not when the value is read — so every default is applied again here. */ -import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; +import { clampNumber } from "./values.js"; import type { PluginContext } from "emdash/plugin"; import { siteHosts } from "./index/paths.js"; diff --git a/packages/analytics/src/store/access.ts b/src/store/access.ts similarity index 100% rename from packages/analytics/src/store/access.ts rename to src/store/access.ts diff --git a/packages/analytics/src/store/rows.ts b/src/store/rows.ts similarity index 100% rename from packages/analytics/src/store/rows.ts rename to src/store/rows.ts diff --git a/packages/analytics/src/store/views.ts b/src/store/views.ts similarity index 100% rename from packages/analytics/src/store/views.ts rename to src/store/views.ts diff --git a/packages/analytics/src/sync/older.ts b/src/sync/older.ts similarity index 100% rename from packages/analytics/src/sync/older.ts rename to src/sync/older.ts diff --git a/packages/analytics/src/sync/scheduler.ts b/src/sync/scheduler.ts similarity index 100% rename from packages/analytics/src/sync/scheduler.ts rename to src/sync/scheduler.ts diff --git a/packages/analytics/src/sync/window.ts b/src/sync/window.ts similarity index 100% rename from packages/analytics/src/sync/window.ts rename to src/sync/window.ts diff --git a/packages/analytics/src/tools/declare.ts b/src/tools/declare.ts similarity index 100% rename from packages/analytics/src/tools/declare.ts rename to src/tools/declare.ts diff --git a/packages/analytics/src/tools/load.ts b/src/tools/load.ts similarity index 100% rename from packages/analytics/src/tools/load.ts rename to src/tools/load.ts diff --git a/packages/analytics/src/ui/blocks.ts b/src/ui/blocks.ts similarity index 100% rename from packages/analytics/src/ui/blocks.ts rename to src/ui/blocks.ts diff --git a/packages/analytics/src/ui/content.ts b/src/ui/content.ts similarity index 100% rename from packages/analytics/src/ui/content.ts rename to src/ui/content.ts diff --git a/packages/analytics/src/ui/format.ts b/src/ui/format.ts similarity index 100% rename from packages/analytics/src/ui/format.ts rename to src/ui/format.ts diff --git a/packages/analytics/src/ui/page.ts b/src/ui/page.ts similarity index 100% rename from packages/analytics/src/ui/page.ts rename to src/ui/page.ts diff --git a/packages/analytics/src/ui/panel.ts b/src/ui/panel.ts similarity index 100% rename from packages/analytics/src/ui/panel.ts rename to src/ui/panel.ts diff --git a/packages/analytics/src/ui/setup.ts b/src/ui/setup.ts similarity index 100% rename from packages/analytics/src/ui/setup.ts rename to src/ui/setup.ts diff --git a/packages/analytics/src/ui/status.ts b/src/ui/status.ts similarity index 100% rename from packages/analytics/src/ui/status.ts rename to src/ui/status.ts diff --git a/packages/analytics/src/ui/widget.ts b/src/ui/widget.ts similarity index 100% rename from packages/analytics/src/ui/widget.ts rename to src/ui/widget.ts diff --git a/packages/shared/src/values.ts b/src/values.ts similarity index 53% rename from packages/shared/src/values.ts rename to src/values.ts index 0b95c14..38b73d7 100644 --- a/packages/shared/src/values.ts +++ b/src/values.ts @@ -1,21 +1,3 @@ -/** - * 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. * diff --git a/packages/analytics/tests/astro.test.ts b/tests/astro.test.ts similarity index 100% rename from packages/analytics/tests/astro.test.ts rename to tests/astro.test.ts diff --git a/packages/analytics/tests/backfill.test.ts b/tests/backfill.test.ts similarity index 100% rename from packages/analytics/tests/backfill.test.ts rename to tests/backfill.test.ts diff --git a/packages/analytics/tests/bridge-calls.ts b/tests/bridge-calls.ts similarity index 100% rename from packages/analytics/tests/bridge-calls.ts rename to tests/bridge-calls.ts diff --git a/packages/analytics/tests/budget.test.ts b/tests/budget.test.ts similarity index 100% rename from packages/analytics/tests/budget.test.ts rename to tests/budget.test.ts diff --git a/packages/analytics/tests/cloudflare.test.ts b/tests/cloudflare.test.ts similarity index 100% rename from packages/analytics/tests/cloudflare.test.ts rename to tests/cloudflare.test.ts diff --git a/packages/analytics/tests/content-sync.test.ts b/tests/content-sync.test.ts similarity index 100% rename from packages/analytics/tests/content-sync.test.ts rename to tests/content-sync.test.ts diff --git a/packages/analytics/tests/content.test.ts b/tests/content.test.ts similarity index 100% rename from packages/analytics/tests/content.test.ts rename to tests/content.test.ts diff --git a/packages/analytics/tests/demo-sync.test.ts b/tests/demo-sync.test.ts similarity index 100% rename from packages/analytics/tests/demo-sync.test.ts rename to tests/demo-sync.test.ts diff --git a/packages/analytics/tests/demo.test.ts b/tests/demo.test.ts similarity index 100% rename from packages/analytics/tests/demo.test.ts rename to tests/demo.test.ts diff --git a/packages/analytics/tests/errors.test.ts b/tests/errors.test.ts similarity index 100% rename from packages/analytics/tests/errors.test.ts rename to tests/errors.test.ts diff --git a/packages/analytics/tests/host.ts b/tests/host.ts similarity index 100% rename from packages/analytics/tests/host.ts rename to tests/host.ts diff --git a/packages/analytics/tests/i18n.test.ts b/tests/i18n.test.ts similarity index 100% rename from packages/analytics/tests/i18n.test.ts rename to tests/i18n.test.ts diff --git a/packages/analytics/tests/page-sync.test.ts b/tests/page-sync.test.ts similarity index 100% rename from packages/analytics/tests/page-sync.test.ts rename to tests/page-sync.test.ts diff --git a/packages/analytics/tests/page.test.ts b/tests/page.test.ts similarity index 100% rename from packages/analytics/tests/page.test.ts rename to tests/page.test.ts diff --git a/packages/analytics/tests/panel.test.ts b/tests/panel.test.ts similarity index 100% rename from packages/analytics/tests/panel.test.ts rename to tests/panel.test.ts diff --git a/packages/analytics/tests/paths.test.ts b/tests/paths.test.ts similarity index 100% rename from packages/analytics/tests/paths.test.ts rename to tests/paths.test.ts diff --git a/packages/analytics/tests/plugin.test.ts b/tests/plugin.test.ts similarity index 100% rename from packages/analytics/tests/plugin.test.ts rename to tests/plugin.test.ts diff --git a/packages/analytics/tests/rows.test.ts b/tests/rows.test.ts similarity index 100% rename from packages/analytics/tests/rows.test.ts rename to tests/rows.test.ts diff --git a/packages/analytics/tests/setup-sync.test.ts b/tests/setup-sync.test.ts similarity index 100% rename from packages/analytics/tests/setup-sync.test.ts rename to tests/setup-sync.test.ts diff --git a/packages/analytics/tests/setup.test.ts b/tests/setup.test.ts similarity index 100% rename from packages/analytics/tests/setup.test.ts rename to tests/setup.test.ts diff --git a/packages/analytics/tests/tools.test.ts b/tests/tools.test.ts similarity index 100% rename from packages/analytics/tests/tools.test.ts rename to tests/tools.test.ts diff --git a/packages/shared/tests/values.test.ts b/tests/values.test.ts similarity index 62% rename from packages/shared/tests/values.test.ts rename to tests/values.test.ts index 85cfa70..cd6199a 100644 --- a/packages/shared/tests/values.test.ts +++ b/tests/values.test.ts @@ -1,17 +1,6 @@ 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([]); - }); -}); +import { clampNumber } from "../src/values.js"; describe("clampNumber", () => { it("clamps, truncates and falls back", () => { diff --git a/packages/analytics/tests/views-sync.test.ts b/tests/views-sync.test.ts similarity index 100% rename from packages/analytics/tests/views-sync.test.ts rename to tests/views-sync.test.ts diff --git a/packages/analytics/tests/views.test.ts b/tests/views.test.ts similarity index 100% rename from packages/analytics/tests/views.test.ts rename to tests/views.test.ts diff --git a/packages/analytics/tests/widget.test.ts b/tests/widget.test.ts similarity index 100% rename from packages/analytics/tests/widget.test.ts rename to tests/widget.test.ts diff --git a/packages/analytics/tests/window.test.ts b/tests/window.test.ts similarity index 100% rename from packages/analytics/tests/window.test.ts rename to tests/window.test.ts diff --git a/packages/analytics/tsconfig.json b/tsconfig.json similarity index 100% rename from packages/analytics/tsconfig.json rename to tsconfig.json diff --git a/packages/analytics/tsdown.config.ts b/tsdown.config.ts similarity index 100% rename from packages/analytics/tsdown.config.ts rename to tsdown.config.ts diff --git a/packages/analytics/vitest.config.ts b/vitest.config.ts similarity index 100% rename from packages/analytics/vitest.config.ts rename to vitest.config.ts