From 3e87809e53ff35e8d723ec40f91e8931570b20ff Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:12:29 +0200 Subject: [PATCH 1/9] feat(shared): the sweep chain and collection listing from the content plugins Copied unchanged from the proof-of-concept repository at ef4884d, where the content plugins use them: `chain.ts` carries a sweep across cron runs with alternating one-shot follow-ups, and `listCollections()` finds every collection through `schema:read`. `parseCollections()` stays for now. The floor rises to 0.39.0 because `ctx.schema` first exists there; the analytics plugin already requires 0.39.0. --- packages/shared/package.json | 4 +-- packages/shared/src/chain.ts | 52 +++++++++++++++++++++++++++++ packages/shared/src/index.ts | 16 ++++++++- packages/shared/src/sweep.ts | 12 ++++++- packages/shared/tests/chain.test.ts | 48 ++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 packages/shared/src/chain.ts create mode 100644 packages/shared/tests/chain.test.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 7e6ae95..75ebc52 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,10 +12,10 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "emdash": ">=0.36.0 <1.0.0" + "emdash": ">=0.39.0 <1.0.0" }, "devDependencies": { - "emdash": ">=0.36.0 <1.0.0", + "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 new file mode 100644 index 0000000..57e9bcd --- /dev/null +++ b/packages/shared/src/chain.ts @@ -0,0 +1,52 @@ +/** + * 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 index 5d87eb0..db9d233 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,17 @@ +export { + followUpPending, + isFollowUp, + scheduleFollowUp, + type ChainEvent, + type SweepChain, +} from "./chain.js"; export { clampNumber, parseCollections } from "./values.js"; -export { nextCollectionPage, resetCursors, type CollectionPage, type PluginContentItem } from "./sweep.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/sweep.ts b/packages/shared/src/sweep.ts index deff3b7..4457f33 100644 --- a/packages/shared/src/sweep.ts +++ b/packages/shared/src/sweep.ts @@ -1,7 +1,7 @@ /** * Cursor-driven collection walk. * - * Both plugins sweep every configured collection page by page across cron + * 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` @@ -34,6 +34,16 @@ export interface CollectionPage { 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"; diff --git a/packages/shared/tests/chain.test.ts b/packages/shared/tests/chain.test.ts new file mode 100644 index 0000000..3dcd5c0 --- /dev/null +++ b/packages/shared/tests/chain.test.ts @@ -0,0 +1,48 @@ +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); + }); +}); From 3809620b05372dbda52d1a5737e05ab0c9c4d8ad Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:12:29 +0200 Subject: [PATCH 2/9] feat: the content-freshness plugin Imported from the proof-of-concept repository at ef4884d as fresh files. A scheduled, network-free audit: overdue schedules, missing or badly sized SEO descriptions, stale entries, forgotten drafts, and routable collections without a URL pattern. Capabilities `content:read` and `schema:read`; it reports and never edits. Aligned with the workspace on the way in: `registry:publish` and `registry:login` instead of the generated `publish` and `login`, `prepublishOnly` builds, repository metadata for trusted publishing, a peer range with a floor and no ceiling, and the analytics plugin's vitest limits (30 s timeouts, one test file at a time). Toolchain unchanged from the proof of concept: plugin-cli 0.12.0, plugin-test 0.2.1, EmDash 0.39.1. --- README.md | 1 + packages/content-freshness/.agents/skills | 1 + packages/content-freshness/.claude/CLAUDE.md | 1 + packages/content-freshness/.claude/skills | 1 + packages/content-freshness/.gitignore | 2 + packages/content-freshness/AGENTS.md | 14 + packages/content-freshness/README.md | 100 +++++++ .../content-freshness/emdash-plugin.jsonc | 45 ++++ packages/content-freshness/package.json | 65 +++++ .../skills/creating-plugins/SKILL.md | 29 +++ packages/content-freshness/src/plugin.ts | 153 +++++++++++ packages/content-freshness/src/report.ts | 222 ++++++++++++++++ packages/content-freshness/src/routing.ts | 16 ++ packages/content-freshness/src/rules.ts | 177 +++++++++++++ packages/content-freshness/src/settings.ts | 69 +++++ packages/content-freshness/src/sweep.ts | 75 ++++++ .../tests/cron-names.test.ts | 74 ++++++ .../tests/permissions.test.ts | 87 +++++++ .../content-freshness/tests/plugin.test.ts | 18 ++ .../content-freshness/tests/report.test.ts | 69 +++++ .../content-freshness/tests/routing.test.ts | 58 +++++ .../content-freshness/tests/rules.test.ts | 148 +++++++++++ .../content-freshness/tests/settings.test.ts | 38 +++ .../content-freshness/tests/sweep.test.ts | 93 +++++++ packages/content-freshness/tsconfig.json | 20 ++ packages/content-freshness/vitest.config.ts | 19 ++ pnpm-lock.yaml | 244 +++++++++++++++--- 27 files changed, 1810 insertions(+), 29 deletions(-) create mode 120000 packages/content-freshness/.agents/skills create mode 120000 packages/content-freshness/.claude/CLAUDE.md create mode 120000 packages/content-freshness/.claude/skills create mode 100644 packages/content-freshness/.gitignore create mode 100644 packages/content-freshness/AGENTS.md create mode 100644 packages/content-freshness/README.md create mode 100644 packages/content-freshness/emdash-plugin.jsonc create mode 100644 packages/content-freshness/package.json create mode 100644 packages/content-freshness/skills/creating-plugins/SKILL.md create mode 100644 packages/content-freshness/src/plugin.ts create mode 100644 packages/content-freshness/src/report.ts create mode 100644 packages/content-freshness/src/routing.ts create mode 100644 packages/content-freshness/src/rules.ts create mode 100644 packages/content-freshness/src/settings.ts create mode 100644 packages/content-freshness/src/sweep.ts create mode 100644 packages/content-freshness/tests/cron-names.test.ts create mode 100644 packages/content-freshness/tests/permissions.test.ts create mode 100644 packages/content-freshness/tests/plugin.test.ts create mode 100644 packages/content-freshness/tests/report.test.ts create mode 100644 packages/content-freshness/tests/routing.test.ts create mode 100644 packages/content-freshness/tests/rules.test.ts create mode 100644 packages/content-freshness/tests/settings.test.ts create mode 100644 packages/content-freshness/tests/sweep.test.ts create mode 100644 packages/content-freshness/tsconfig.json create mode 100644 packages/content-freshness/vitest.config.ts diff --git a/README.md b/README.md index 9901b68..51d2324 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Plugins for [EmDash](https://github.com/emdash-cms/emdash) by [Eisbachcode](http | Package | What it does | |---|---| | [`@eisbachcode/emdash-plugin-analytics`](packages/analytics) | Cloudflare Web Analytics on the EmDash dashboard and next to your content: traffic per entry, a setup check, German and English | +| [`@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 | ## Development diff --git a/packages/content-freshness/.agents/skills b/packages/content-freshness/.agents/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/content-freshness/.agents/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/content-freshness/.claude/CLAUDE.md b/packages/content-freshness/.claude/CLAUDE.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/packages/content-freshness/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/packages/content-freshness/.claude/skills b/packages/content-freshness/.claude/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/content-freshness/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/content-freshness/.gitignore b/packages/content-freshness/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/content-freshness/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/content-freshness/AGENTS.md b/packages/content-freshness/AGENTS.md new file mode 100644 index 0000000..d709ee5 --- /dev/null +++ b/packages/content-freshness/AGENTS.md @@ -0,0 +1,14 @@ +# 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.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 new file mode 100644 index 0000000..902ef97 --- /dev/null +++ b/packages/content-freshness/README.md @@ -0,0 +1,100 @@ +# 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, the date passed, still not published | 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 | +| SEO description too short or too long | nice to fix | +| Draft abandoned for longer than your threshold | nice to fix | + +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. + +A fixed entry drops out of the report on the next sweep — the audit stores the +current state, not a history. + +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. + +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. + +## 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. + +Then activate it once under **Admin → Extensions**. Every collection on the +site is audited. + +**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. | +| 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 | One collection list, one `content.list` and two batched storage calls per run, whatever the page size. | +| Schedule | `0 4 * * *` | Any cron expression. | + +## Develop + +```sh +pnpm install +pnpm test +pnpm typecheck +pnpm build +``` + +## Compatibility + +| EmDash | Plugin test suite | +| ------ | ----------------- | +| 0.39.1 | 38 passed | +| 0.39.0 | 38 passed | + +Declared range: `emdash >=0.39.0 <1.0.0`. The table covers **every released +version inside it** — 0.39.0 and 0.39.1 are the only ones published, 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` from the repo root. Last run +2026-09-24, when 0.39.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 new file mode 100644 index 0000000..48a500c --- /dev/null +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -0,0 +1,45 @@ +{ + "$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 for staleness: entries untouched for too long, missing or badly sized SEO descriptions, forgotten drafts, schedules that never fired, and collections without a URL pattern.", + "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, rule). Re-running overwrites, so a fixed + // entry drops out of the report on the next sweep. + "findings": { "indexes": ["collection", "severity", "rule", "entryId"] }, + }, + + "admin": { + "pages": [ + { "path": "/report", "label": "Content freshness", "icon": "clock" }, + { "path": "/settings", "label": "Freshness settings", "icon": "settings" }, + ], + "widgets": [{ "id": "summary", "title": "Needs attention", "size": "third" }], + }, +} diff --git a/packages/content-freshness/package.json b/packages/content-freshness/package.json new file mode 100644 index 0000000..6dc7266 --- /dev/null +++ b/packages/content-freshness/package.json @@ -0,0 +1,65 @@ +{ + "name": "@eisbachcode/emdash-plugin-content-freshness", + "version": "0.1.0", + "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/plugin-cli": "0.12.0", + "@emdash-cms/plugin-test": "0.2.1", + "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 new file mode 100644 index 0000000..336829d --- /dev/null +++ b/packages/content-freshness/skills/creating-plugins/SKILL.md @@ -0,0 +1,29 @@ +--- +name: creating-plugins +description: Build, test, and publish this sandboxed EmDash plugin. Use for changes to emdash-plugin.jsonc, src/plugin.ts, hooks, routes, capabilities, storage, Block Kit admin UI, bundling, or releases. +--- + +# Creating EmDash plugins + +Read `emdash-plugin.jsonc` and `src/plugin.ts` before editing. The manifest is the identity and trust contract; the source contains runtime hooks and routes. + +## Runtime rules + +- Assign the runtime definition to a `SandboxedPlugin`-typed constant and export it as default from `src/plugin.ts`. +- Use Web APIs. Do not import Node.js built-ins into plugin runtime code. +- Declare every runtime API in `capabilities` and every network destination in `allowedHosts`. +- Use `ctx.storage` for queryable records and `ctx.kv` for key-value state. +- Use Block Kit for sandboxed admin UI. Do not ship browser React components. +- Treat public routes as internet-facing and validate their inputs. + +## Validation + +Use the package scripts in this repository. The test script builds the plugin and runs it inside workerd through EmDash's production sandbox wrapper and host bridge. Use `createPluginTestHost()` to invoke hooks and routes, create content fixtures, and inspect plugin KV or declared storage. Dispose the host after each test so its bindings reset. + +Before handing off a change, run validation, typecheck, tests, and build. A release also requires a version bump in `package.json` when runtime behavior or the trust contract changes. + +## Publishing + +Use the local publish script for a release started from this computer. Use the release-setup script for GitHub Actions. Setup detects a root Changesets configuration and offers to follow packages released by Changesets; otherwise it uses package tags. Connect the generated reusable workflow to the existing Changesets publish job by passing its published-package output. Changesets Action v1 names the step output `publishedPackages`; v2 names it `published-packages`. Expose it as a `published-packages` job output and pass it to the generated workflow from a dependent job when Changesets reports `published == 'true'`. The first automated release connects the repository workflow; later packages reuse it only when their signed profiles name the same repository. + +For complete EmDash patterns and API details, use https://docs.emdashcms.com/plugins/creating-plugins/. diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts new file mode 100644 index 0000000..74fb7c8 --- /dev/null +++ b/packages/content-freshness/src/plugin.ts @@ -0,0 +1,153 @@ +/** + * 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 type { PluginContext, SandboxedPlugin } from "emdash/plugin"; + +import { buildReportPage, buildSettingsPage, buildWidget, countFindings } from "./report.js"; +import { + followUpPending, + hasRole, + isFollowUp, + listCollections, + ROLE, + scheduleFollowUp, + type SweepChain, +} from "@eisbachcode/emdash-plugin-shared"; +import { readSettings, writeSettings } from "./settings.js"; +import { auditNextPage, finishSweep } from "./sweep.js"; + +async function collectionSlugs(ctx: PluginContext): Promise { + return (await listCollections(ctx)).map((collection) => collection.slug); +} + +/** Audit the next page of the next unfinished collection. Returns whether there is more to do. */ +async function auditStep(ctx: PluginContext): Promise { + const settings = await readSettings(ctx); + const collections = await collectionSlugs(ctx); + if (collections.length === 0) { + ctx.log.warn("No collections to audit"); + return false; + } + + const result = await auditNextPage(ctx, settings, collections); + if (!result) { + await finishSweep(ctx, collections); + ctx.log.info("Freshness audit complete"); + return false; + } + ctx.log.info(`Audited ${result.entries} entries in ${result.collection}, ${result.found} findings`); + return true; +} + +interface AdminInteraction { + type: string; + page?: string; + action_id?: string; + values?: Record; +} + +/** + * The recurring task, and the one-shot the "Audit 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 recurring audit with a task that runs once and never again. The + * button schedules its own name instead, and the cron handler answers to + * both. + */ +const AUDIT_TASK = "audit"; +const AUDIT_NOW_TASK = "audit-now"; + +/** An audit carries on in follow-up runs until it is done. 500 runs is 25,000 entries at the default page size. */ +const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSteps: 500 }; + +const plugin = { + hooks: { + "plugin:activate": { + handler: async (_event, ctx) => { + const settings = await readSettings(ctx); + await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); + ctx.log.info(`Freshness audit scheduled: ${settings.schedule}`); + }, + }, + + cron: { + handler: async (event, ctx) => { + const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; + if (!starts && !isFollowUp(CHAIN, event.name)) return; + // A follow-up already waiting means an audit is under way. + if (starts && (await followUpPending(ctx, CHAIN))) return; + + if (!(await auditStep(ctx))) return; + if (!(await scheduleFollowUp(ctx, CHAIN, event))) { + ctx.log.warn(`Audit paused after ${CHAIN.maxSteps} runs; it continues at the next scheduled run`); + } + }, + }, + }, + + 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; + + if (interaction.type === "page_load") { + if (interaction.page === "widget:summary") return buildWidget(ctx); + if (interaction.page === "/settings") return buildSettingsPage(ctx); + return buildReportPage(ctx); + } + + if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { + if (!hasRole(routeCtx.user, ROLE.ADMIN)) { + return { + ...(await buildSettingsPage(ctx)), + toast: { message: "Only an administrator can change these settings.", type: "error" }, + }; + } + await writeSettings(ctx, interaction.values ?? {}); + const settings = await readSettings(ctx); + // Re-register so a changed cron expression takes effect. + await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); + return buildSettingsPage(ctx); + } + + if (interaction.type === "block_action" && interaction.action_id === "audit_now") { + await finishSweep(ctx, await collectionSlugs(ctx)); + await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); + return buildReportPage(ctx); + } + + return { blocks: [] }; + }, + }, + + status: { + handler: async (_routeCtx, ctx) => { + return { + collections: await collectionSlugs(ctx), + lastSweepFinishedAt: await ctx.kv.get("state:lastSweepFinishedAt"), + counts: await countFindings(ctx), + }; + }, + }, + }, +} 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; diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts new file mode 100644 index 0000000..fa744fe --- /dev/null +++ b/packages/content-freshness/src/report.ts @@ -0,0 +1,222 @@ +/** + * Block Kit surfaces: report page, settings form, dashboard widget. + */ + +import { listCollections } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { collectionsWithoutUrlPattern } from "./routing.js"; +import { readSettings } from "./settings.js"; +import type { StoredFinding } from "./sweep.js"; + +type Block = Record; + +const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 } as const; + +export interface Counts { + high: number; + medium: number; + low: number; + total: number; +} + +export async function countFindings(ctx: PluginContext): Promise { + const [high, medium, low, total] = await Promise.all([ + ctx.storage.findings.count({ severity: "high" }), + ctx.storage.findings.count({ severity: "medium" }), + ctx.storage.findings.count({ severity: "low" }), + ctx.storage.findings.count(), + ]); + return { high, medium, low, total }; +} + +/** A warning about collections whose public URLs EmDash gets wrong, or nothing. */ +async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { + const missing = collectionsWithoutUrlPattern(await listCollections(ctx)); + if (missing.length === 0) return []; + const names = missing.map((collection) => collection.label || collection.slug).join(", "); + const explanation = + "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."; + return [ + { + type: "banner", + variant: "alert", + title: `No URL pattern: ${names}`, + description: detail ? explanation : "Sitemap links for these collections may be broken.", + }, + ]; +} + +export async function buildWidget(ctx: PluginContext): Promise<{ blocks: Block[] }> { + const counts = await countFindings(ctx); + const finished = await ctx.kv.get("state:lastSweepFinishedAt"); + const banner = await urlPatternBanner(ctx, false); + + if (counts.total === 0) { + if (banner.length > 0) return { blocks: banner }; + return { + blocks: [ + { + type: "empty", + icon: "check", + title: finished ? "Everything current" : "First audit pending", + description: finished ? `Last audit ${finished.slice(0, 10)}.` : "Runs on the next scheduled sweep.", + }, + ], + }; + } + + return { + blocks: [ + ...banner, + { + type: "stats", + stats: [ + { label: "Urgent", value: String(counts.high) }, + { label: "Should fix", value: String(counts.medium) }, + { label: "Nice to fix", value: String(counts.low) }, + ], + }, + { type: "context", text: finished ? `Last audit ${finished.slice(0, 10)}` : "Audit in progress" }, + ], + }; +} + +export async function buildReportPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { + const banner = await urlPatternBanner(ctx, true); + const counts = await countFindings(ctx); + const stored = await ctx.storage.findings.query({ limit: 200 }); + const rows = stored.items + .map(({ data }) => data as StoredFinding) + .sort( + (a, b) => + SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || + a.collection.localeCompare(b.collection) || + (a.slug ?? a.entryId).localeCompare(b.slug ?? b.entryId), + ); + + const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; + + if (rows.length === 0) { + return { + blocks: [ + { type: "header", text: "Content freshness" }, + ...banner, + { + type: "empty", + icon: "check", + title: "Nothing needs attention", + description: "No stale entries, no missing descriptions, no overdue schedules.", + }, + ], + }; + } + + return { + blocks: [ + { type: "header", text: "Content freshness" }, + ...banner, + { + type: "stats", + stats: [ + { label: "Urgent", value: String(counts.high) }, + { label: "Should fix", value: String(counts.medium) }, + { label: "Nice to fix", value: String(counts.low) }, + { label: "Total", value: String(counts.total) }, + ], + }, + { + type: "table", + columns: [ + { key: "severity", label: "Priority" }, + { key: "entry", label: "Entry" }, + ...(multilingual ? [{ key: "locale", label: "Language" }] : []), + { key: "collection", label: "Collection" }, + { key: "detail", label: "Finding" }, + { key: "updated", label: "Last touched" }, + ], + rows: rows.map((row) => ({ + severity: row.severity, + entry: row.slug ?? row.entryId, + locale: row.locale ?? "", + collection: row.collection, + detail: row.detail, + updated: row.entryUpdatedAt.slice(0, 10), + })), + }, + { + type: "actions", + elements: [{ type: "button", action_id: "audit_now", label: "Re-run the audit now" }], + }, + ], + }; +} + +export async function buildSettingsPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { + const settings = await readSettings(ctx); + return { + blocks: [ + { type: "header", text: "Freshness settings" }, + { + type: "context", + text: "Every collection on the site is audited.", + }, + { + type: "form", + action_id: "save_settings", + elements: [ + { + type: "number_input", + action_id: "staleMonths", + label: "Months before a published entry counts as stale", + min: 1, + max: 120, + initial_value: settings.staleMonths, + }, + { + type: "number_input", + action_id: "draftMonths", + label: "Months before a draft counts as forgotten", + min: 1, + max: 120, + initial_value: settings.draftMonths, + }, + { + type: "number_input", + action_id: "descriptionMin", + label: "Shortest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMin, + }, + { + type: "number_input", + action_id: "descriptionMax", + label: "Longest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMax, + }, + { + type: "toggle", + action_id: "reportMissingDescriptions", + label: "Report entries without an SEO description", + description: "Switch off if your templates always render a description of their own.", + initial_value: settings.reportMissingDescriptions, + }, + { + type: "number_input", + action_id: "pageSize", + label: "Entries per run", + min: 1, + max: 200, + initial_value: settings.pageSize, + }, + { type: "text_input", action_id: "schedule", label: "Schedule (cron)", initial_value: settings.schedule }, + ], + }, + ], + }; +} diff --git a/packages/content-freshness/src/routing.ts b/packages/content-freshness/src/routing.ts new file mode 100644 index 0000000..d957e62 --- /dev/null +++ b/packages/content-freshness/src/routing.ts @@ -0,0 +1,16 @@ +/** + * 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 new file mode 100644 index 0000000..0a82e78 --- /dev/null +++ b/packages/content-freshness/src/rules.ts @@ -0,0 +1,177 @@ +/** + * The freshness rules — pure functions over one entry. + * + * Every rule reads fields the content API already returns + * (`updatedAt`, `status`, `scheduledAt`, `seo`), so the whole audit runs + * without network access and without knowing the collection's schema. + */ + +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", +] as const; + +export type Rule = (typeof RULES)[number]; + +export interface Finding { + collection: string; + entryId: string; + slug: string | null; + locale: string | null; + rule: Rule; + severity: Severity; + /** One sentence, shown verbatim in the report table. */ + detail: string; + entryUpdatedAt: string; +} + +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; +} + +export const DEFAULT_THRESHOLDS: Thresholds = { + staleMonths: 12, + draftMonths: 6, + descriptionMin: 50, + descriptionMax: 160, + reportMissingDescriptions: true, +}; + +/** + * 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, + collection: string, + thresholds: Thresholds, + now: Date, +): Finding[] { + const findings: Finding[] = []; + const base = { + collection, + entryId: entry.id, + slug: entry.slug, + locale: entry.locale ?? null, + entryUpdatedAt: entry.updatedAt, + }; + + const published = entry.status === "published"; + const updatedAt = parseDate(entry.updatedAt); + + // A schedule that came and went without publishing. Highest severity + // because someone expected this to be live — and it is a failure mode + // this stack actually produces: with autosave the publish button does + // not re-enable, so a scheduled draft can sit there indefinitely. + const scheduledAt = parseDate(entry.scheduledAt ?? null); + if (scheduledAt && !published && scheduledAt < now) { + findings.push({ + ...base, + rule: "overdue-schedule", + severity: "high", + detail: `Scheduled for ${scheduledAt.toISOString().slice(0, 10)} but still ${entry.status}.`, + }); + } + + // `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); + findings.push({ + ...base, + rule: "missing-description", + severity: fallback ? "low" : "medium", + detail: fallback + ? `No SEO description; templates often show the "${fallback}" field instead.` + : "Published without an SEO description.", + }); + } + } else if ( + description.length < thresholds.descriptionMin || + description.length > thresholds.descriptionMax + ) { + findings.push({ + ...base, + rule: "description-length", + severity: "low", + detail: `SEO description is ${description.length} characters; aim for ${thresholds.descriptionMin}–${thresholds.descriptionMax}.`, + }); + } + } + + if (updatedAt) { + if (published && updatedAt < subtractMonths(now, thresholds.staleMonths)) { + findings.push({ + ...base, + rule: "stale", + severity: "medium", + detail: `Not touched since ${updatedAt.toISOString().slice(0, 10)}.`, + }); + } + if (entry.status === "draft" && updatedAt < subtractMonths(now, thresholds.draftMonths)) { + findings.push({ + ...base, + rule: "stale-draft", + severity: "low", + detail: `Draft untouched since ${updatedAt.toISOString().slice(0, 10)}.`, + }); + } + } + + return findings; +} + +/** + * `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 day = 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(day, lastDay)); + return shifted; +} + +/** 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; +} + +/** Stable storage id, so a re-run overwrites instead of duplicating. */ +export function findingId(collection: string, entryId: string, rule: Rule): string { + return `${collection}:${entryId}:${rule}`; +} diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts new file mode 100644 index 0000000..64e5b46 --- /dev/null +++ b/packages/content-freshness/src/settings.ts @@ -0,0 +1,69 @@ +/** + * Settings in plugin KV under `settings:*`. A standard-format plugin has + * no declarative `settingsSchema` — that is native-only — so the form is + * hand-built in `report.ts`. + */ + +import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; + +export interface Settings extends Thresholds { + /** Entries per cron tick. */ + pageSize: number; + schedule: string; +} + +export const DEFAULT_SETTINGS: Settings = { + ...DEFAULT_THRESHOLDS, + pageSize: 50, + schedule: "0 4 * * *", +}; + +export async function readSettings(ctx: PluginContext): Promise { + const [pageSize, schedule, staleMonths, draftMonths, descriptionMin, descriptionMax, reportMissing] = + await Promise.all([ + ctx.kv.get("settings:pageSize"), + ctx.kv.get("settings:schedule"), + ctx.kv.get("settings:staleMonths"), + ctx.kv.get("settings:draftMonths"), + ctx.kv.get("settings:descriptionMin"), + ctx.kv.get("settings:descriptionMax"), + ctx.kv.get("settings:reportMissingDescriptions"), + ]); + + const min = clampNumber(descriptionMin, 0, 300, DEFAULT_SETTINGS.descriptionMin); + const max = clampNumber(descriptionMax, 0, 300, DEFAULT_SETTINGS.descriptionMax); + + return { + pageSize: clampNumber(pageSize, 1, 200, DEFAULT_SETTINGS.pageSize), + schedule: typeof schedule === "string" && schedule.trim() ? schedule.trim() : DEFAULT_SETTINGS.schedule, + staleMonths: clampNumber(staleMonths, 1, 120, DEFAULT_SETTINGS.staleMonths), + draftMonths: clampNumber(draftMonths, 1, 120, DEFAULT_SETTINGS.draftMonths), + descriptionMin: Math.min(min, max), + descriptionMax: Math.max(min, max), + reportMissingDescriptions: + typeof reportMissing === "boolean" ? reportMissing : DEFAULT_SETTINGS.reportMissingDescriptions, + }; +} + +export async function writeSettings(ctx: PluginContext, values: Record): Promise { + if (typeof values.schedule === "string" && values.schedule.trim()) + await ctx.kv.set("settings:schedule", values.schedule.trim()); + if (typeof values.reportMissingDescriptions === "boolean") + await ctx.kv.set("settings:reportMissingDescriptions", values.reportMissingDescriptions); + + const numbers: Array<[string, number, number, number]> = [ + ["pageSize", 1, 200, DEFAULT_SETTINGS.pageSize], + ["staleMonths", 1, 120, DEFAULT_SETTINGS.staleMonths], + ["draftMonths", 1, 120, DEFAULT_SETTINGS.draftMonths], + ["descriptionMin", 0, 300, DEFAULT_SETTINGS.descriptionMin], + ["descriptionMax", 0, 300, DEFAULT_SETTINGS.descriptionMax], + ]; + for (const [key, min, max, fallback] of numbers) { + if (values[key] !== undefined) { + await ctx.kv.set(`settings:${key}`, clampNumber(values[key], min, max, fallback)); + } + } +} diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts new file mode 100644 index 0000000..d552ae1 --- /dev/null +++ b/packages/content-freshness/src/sweep.ts @@ -0,0 +1,75 @@ +/** + * One cron tick: audit the next page of the next unfinished collection. + * + * Cheaper than the link checker, because evaluation is local — no + * outbound request per entry. The cost per tick is one `content.list`, + * one `putMany` and one `deleteMany`, regardless of page size, which + * matters under the sandbox's 10-subrequest budget (the limit counts + * service-binding calls, so `ctx.storage` spends from it too). + */ + +import { nextCollectionPage, resetCursors } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { evaluateEntry, findingId, RULES, type Finding } from "./rules.js"; +import type { Settings } from "./settings.js"; + +export const CURSOR_PREFIX = "state:cursor:"; + +export interface StoredFinding extends Finding { + foundAt: string; +} + +export interface SweepResult { + collection: string; + entries: number; + found: number; + /** True when this page finished the last collection. */ + complete: boolean; +} + +export async function auditNextPage( + ctx: PluginContext, + settings: Settings, + collections: string[], +): Promise { + const page = await nextCollectionPage(ctx, collections, CURSOR_PREFIX, settings.pageSize); + if (!page) return null; + + const now = new Date(); + const writes: Array<{ id: string; data: StoredFinding }> = []; + const clears: string[] = []; + + for (const entry of page.items) { + const findings = evaluateEntry(entry, page.collection, settings, now); + const fired = new Set(findings.map((finding) => finding.rule)); + + for (const finding of findings) { + writes.push({ + id: findingId(page.collection, entry.id, finding.rule), + data: { ...finding, foundAt: now.toISOString() }, + }); + } + + // A rule that no longer fires had its finding fixed. Clearing the + // row here is what keeps the report from accumulating history. + for (const rule of RULES) { + if (!fired.has(rule)) clears.push(findingId(page.collection, entry.id, rule)); + } + } + + if (writes.length > 0) await ctx.storage.findings.putMany(writes); + if (clears.length > 0) await ctx.storage.findings.deleteMany(clears); + + return { + collection: page.collection, + entries: page.items.length, + found: writes.length, + complete: false, + }; +} + +export async function finishSweep(ctx: PluginContext, collections: string[]): Promise { + await resetCursors(ctx, collections, CURSOR_PREFIX); + await ctx.kv.set("state:lastSweepFinishedAt", new Date().toISOString()); +} diff --git a/packages/content-freshness/tests/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts new file mode 100644 index 0000000..97e9e1a --- /dev/null +++ b/packages/content-freshness/tests/cron-names.test.ts @@ -0,0 +1,74 @@ +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: `readSettings` reads plugin KV and nothing else. */ +function fakeContext() { + const warns: string[] = []; + const reads: string[] = []; + const ctx = { + kv: { + async get(key: string) { + reads.push(key); + return null; + }, + }, + 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); + }); +}); diff --git a/packages/content-freshness/tests/permissions.test.ts b/packages/content-freshness/tests/permissions.test.ts new file mode 100644 index 0000000..aac9afe --- /dev/null +++ b/packages/content-freshness/tests/permissions.test.ts @@ -0,0 +1,87 @@ +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).toBeUndefined(); + }); +}); diff --git a/packages/content-freshness/tests/plugin.test.ts b/packages/content-freshness/tests/plugin.test.ts new file mode 100644 index 0000000..6ab9ced --- /dev/null +++ b/packages/content-freshness/tests/plugin.test.ts @@ -0,0 +1,18 @@ +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(["cron", "plugin:activate"]); + }); + + it("registers the admin and status routes", () => { + expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "status"]); + }); +}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts new file mode 100644 index 0000000..c57c141 --- /dev/null +++ b/packages/content-freshness/tests/report.test.ts @@ -0,0 +1,69 @@ +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import { buildReportPage, buildSettingsPage } from "../src/report.js"; +import type { StoredFinding } from "../src/sweep.js"; + +function finding(slug: string, locale: string): StoredFinding { + return { + collection: "pages", + entryId: `01${slug.toUpperCase()}`, + slug, + locale, + rule: "missing-description", + severity: "medium", + detail: "Published without an SEO description.", + entryUpdatedAt: "2026-09-17T00:00:00.000Z", + foundAt: "2026-09-24T00:00:00.000Z", + }; +} + +function fakeContext(findings: StoredFinding[], kv: Record = {}) { + return { + kv: { get: async (key: string) => kv[key] ?? null }, + schema: { listCollections: async () => [] }, + storage: { + findings: { + count: async () => findings.length, + query: async () => ({ items: findings.map((data) => ({ id: data.entryId, 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 findings table", () => { + it("tells 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([finding("about", "de"), finding("about", "en")])); + const rows = table(blocks)?.rows ?? []; + expect(rows.map((row) => row.locale).sort()).toEqual(["de", "en"]); + expect(table(blocks)?.columns.map((column) => column.key)).toContain("locale"); + }); + + it("leaves the language out on a single-language site", async () => { + const { blocks } = await buildReportPage(fakeContext([finding("about", "en"), finding("contact", "en")])); + 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`; anything else leaves the field empty. + const { blocks } = await buildSettingsPage( + fakeContext([], { "settings:staleMonths": 18, "settings:reportMissingDescriptions": false }), + ); + const form = blocks.find((block) => block.type === "form") as { + elements: Array<{ action_id: string; initial_value?: unknown }>; + }; + const value = (id: string) => form.elements.find((element) => element.action_id === id)?.initial_value; + 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 new file mode 100644 index 0000000..94ea5b1 --- /dev/null +++ b/packages/content-freshness/tests/routing.test.ts @@ -0,0 +1,58 @@ +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import { buildReportPage, buildWidget } from "../src/report.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 }; + +function fakeContext(collections: Collection[]) { + return { + kv: { get: async () => null }, + schema: { listCollections: async () => collections }, + storage: { + findings: { + count: async () => 0, + query: async () => ({ items: [], cursor: null }), + }, + }, + } as unknown as PluginContext; +} + +function banners(blocks: Array>) { + return blocks.filter((block) => 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); + 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); + 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)).blocks)).toHaveLength(0); + expect(banners((await buildWidget(ctx)).blocks)).toHaveLength(0); + }); +}); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts new file mode 100644 index 0000000..0d399d7 --- /dev/null +++ b/packages/content-freshness/tests/rules.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_THRESHOLDS, evaluateEntry, parseDate, subtractMonths } 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, "posts", DEFAULT_THRESHOLDS, NOW).map((finding) => finding.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("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 findings = evaluateEntry( + entry({ status: "draft", scheduledAt: "2026-08-01T00:00:00.000Z" }), + "posts", + DEFAULT_THRESHOLDS, + NOW, + ); + const overdue = findings.find((finding) => finding.rule === "overdue-schedule"); + expect(overdue?.severity).toBe("high"); + expect(overdue?.detail).toContain("2026-08-01"); + }); + + it("ignores a schedule in the future, and one that already published", () => { + expect(rules(entry({ status: "draft", scheduledAt: "2027-01-01T00:00:00.000Z" }))).not.toContain( + "overdue-schedule", + ); + expect(rules(entry({ status: "published", scheduledAt: "2026-08-01T00:00:00.000Z" }))).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 [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); + expect(finding).toMatchObject({ rule: "missing-description", severity: "low" }); + expect(finding?.detail).toContain(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 [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); + expect(finding?.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 }), "posts", 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 }), "posts", off, NOW).map((f) => f.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); + }); +}); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts new file mode 100644 index 0000000..13137a1 --- /dev/null +++ b/packages/content-freshness/tests/settings.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, readSettings, writeSettings } from "../src/settings.js"; + +describe("readSettings", () => { + it("returns defaults on an empty store", async () => { + expect(await readSettings(fakeKv({}))).toEqual(DEFAULT_SETTINGS); + }); + + it("orders the description bounds even if they are stored the wrong way round", async () => { + const settings = await readSettings( + fakeKv({ "settings:descriptionMin": 200, "settings:descriptionMax": 40 }), + ); + expect([settings.descriptionMin, settings.descriptionMax]).toEqual([40, 200]); + }); +}); + +describe("writeSettings", () => { + it("clamps numbers and ignores unknown keys", async () => { + const store: Record = {}; + await writeSettings(fakeKv(store), { staleMonths: 9999, nonsense: true }); + expect(store["settings:staleMonths"]).toBe(120); + expect(store).not.toHaveProperty("nonsense"); + }); +}); + +function fakeKv(store: 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 () => [], + }, + } as unknown as import("emdash/plugin").PluginContext; +} diff --git a/packages/content-freshness/tests/sweep.test.ts b/packages/content-freshness/tests/sweep.test.ts new file mode 100644 index 0000000..b0e3a95 --- /dev/null +++ b/packages/content-freshness/tests/sweep.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, type Settings } from "../src/settings.js"; +import { auditNextPage } from "../src/sweep.js"; + +const SETTINGS: Settings = { ...DEFAULT_SETTINGS, pageSize: 10 }; +const COLLECTIONS = ["posts"]; + +describe("auditNextPage", () => { + it("writes findings and clears the rules that no longer fire, in one call each", async () => { + const writes: Array<{ id: string }> = []; + const clears: string[] = []; + let putCalls = 0; + let deleteCalls = 0; + + const ctx = fakeCtx( + [{ items: [staleEntry()], cursor: null }], + { + putMany: async (items) => { + putCalls += 1; + writes.push(...items); + }, + deleteMany: async (ids) => { + deleteCalls += 1; + clears.push(...ids); + return ids.length; + }, + }, + ); + + const result = await auditNextPage(ctx, SETTINGS, COLLECTIONS); + expect(result).toMatchObject({ collection: "posts", entries: 1, found: 1 }); + expect(writes.map((item) => item.id)).toEqual(["posts:01STALE:stale"]); + // The other four rules are cleared so a fixed finding disappears. + expect(clears).toHaveLength(4); + // Batched: one write call and one delete call for the whole page. + expect([putCalls, deleteCalls]).toEqual([1, 1]); + }); + + it("returns null when every collection is walked", async () => { + const ctx = fakeCtx([], {}); + // The fake serves an empty last page, so the cursor lands on "done". + await auditNextPage(ctx, SETTINGS, COLLECTIONS); + expect(await auditNextPage(ctx, SETTINGS, COLLECTIONS)).toBeNull(); + }); +}); + +function staleEntry() { + return { + id: "01STALE", + type: "posts", + slug: "old-news", + status: "published", + locale: "en", + data: {}, + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + publishedAt: "2020-01-01T00:00:00.000Z", + }; +} + +function fakeCtx( + pages: Array<{ items: ReturnType[]; cursor: string | null }>, + storage: { + putMany?: (items: Array<{ id: string; data: unknown }>) => Promise; + deleteMany?: (ids: string[]) => Promise; + }, +) { + const store: Record = {}; + let served = 0; + 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 () => pages[served++] ?? { items: [], cursor: null }, + get: async () => null, + }, + storage: { + findings: { + putMany: storage.putMany ?? (async () => {}), + deleteMany: storage.deleteMany ?? (async () => 0), + count: async () => 0, + query: async () => ({ items: [], cursor: null }), + }, + }, + } as unknown as import("emdash/plugin").PluginContext; +} diff --git a/packages/content-freshness/tsconfig.json b/packages/content-freshness/tsconfig.json new file mode 100644 index 0000000..8268866 --- /dev/null +++ b/packages/content-freshness/tsconfig.json @@ -0,0 +1,20 @@ +{ + "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 new file mode 100644 index 0000000..856fcc5 --- /dev/null +++ b/packages/content-freshness/vitest.config.ts @@ -0,0 +1,19 @@ +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/pnpm-lock.yaml b/pnpm-lock.yaml index 989fa5e..a27f969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,16 +22,37 @@ importers: version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@emdash-cms/plugin-cli': specifier: 0.12.0 - version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + 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.0 - version: 0.2.0(59949aeb10c26a9714a29cfa8eddca48) + 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) tsdown: specifier: 0.20.3 - version: 0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@5.9.3) + version: 0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + + packages/content-freshness: + 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.1 + version: 0.2.1(f64bdd591a184e04878ebed7d065ed68) + 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) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -42,7 +63,7 @@ importers: packages/shared: devDependencies: emdash: - specifier: '>=0.36.0 <1.0.0' + 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) typescript: specifier: ^5.9.0 @@ -620,6 +641,28 @@ packages: react: optional: true + '@emdash-cms/cloudflare@0.39.1': + resolution: {integrity: sha512-Hx2ZABfc5MBUwC8vVvHPGk+snQ1JP28+g8M7+vjxyWOEqBIlRU+y3hPGFKy7Nttbqf8fv1MCa1SHcojSBf6Evw==} + 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==} @@ -636,6 +679,12 @@ packages: vite: ^8.0.0 vitest: ^4.1.0 + '@emdash-cms/plugin-test@0.2.1': + resolution: {integrity: sha512-uuzobdT9Vw+8yUxlkI3uWYI/8gZuLiMA6MBNb590SvZ3clRLzLXvck6fskoiFsngAyDgO6SLK5tT2YTIMNr7TQ==} + peerDependencies: + vite: ^8.0.0 + vitest: ^4.1.0 + '@emdash-cms/plugin-types@0.4.0': resolution: {integrity: sha512-HqYiU8xL/uTalcPHcVI5K/n3Z1joa1WpvI0/itG+RmJwMWGHLv5ATmo9OeChczjfb31orJ4wKfFQIEXbNxrsKA==} @@ -4553,6 +4602,28 @@ 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)': @@ -4851,6 +4922,22 @@ snapshots: - 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.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) @@ -4895,6 +4982,50 @@ snapshots: - typescript - utf-8-validate + '@emdash-cms/cloudflare@0.39.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.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) + 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 @@ -4905,7 +5036,7 @@ snapshots: '@wordpress/block-serialization-default-parser': 5.56.0 parse5: 7.3.0 - '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + '@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)': dependencies: '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) @@ -4924,7 +5055,7 @@ snapshots: jsonc-parser: 3.3.1 modern-tar: 0.7.7 picocolors: 1.1.1 - tsdown: 0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.3) + tsdown: 0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@6.0.3) typescript: 6.0.3 zod: 4.5.4 transitivePeerDependencies: @@ -4944,12 +5075,12 @@ snapshots: - unplugin-unused - vue-tsc - '@emdash-cms/plugin-test@0.2.0(59949aeb10c26a9714a29cfa8eddca48)': + '@emdash-cms/plugin-test@0.2.0(f863f6b7fe5271f41d62dece9033c2f3)': dependencies: '@cloudflare/vitest-plugin': 1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))) '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@emdash-cms/cloudflare': 0.39.0(a7ee94a070d1e2ec5745e252646edfd0) - '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@emdash-cms/plugin-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.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) kysely: 0.29.6 @@ -5006,6 +5137,68 @@ snapshots: - wrangler - zod + '@emdash-cms/plugin-test@0.2.1(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.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.6.5) + '@emdash-cms/cloudflare': 0.39.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.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) + 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 @@ -5554,13 +5747,6 @@ snapshots: '@tybys/wasm-util': 0.10.4 optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.11.3 - '@tybys/wasm-util': 0.10.4 - optional: true - '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -5773,9 +5959,9 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -7709,7 +7895,7 @@ snapshots: nlcst-to-string: 4.0.0 unist-util-visit: 5.1.0 - rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@5.9.3): + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 @@ -7720,13 +7906,13 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.14.3 obug: 2.2.1 - rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver - rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.3): + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 @@ -7737,7 +7923,7 @@ snapshots: dts-resolver: 2.1.3 get-tsconfig: 4.14.3 obug: 2.2.1 - rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) optionalDependencies: typescript: 6.0.3 transitivePeerDependencies: @@ -7764,7 +7950,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 - rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3): + rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.112.0 '@rolldown/pluginutils': 1.0.0-rc.3 @@ -7779,7 +7965,7 @@ snapshots: '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 transitivePeerDependencies: @@ -8034,7 +8220,7 @@ snapshots: trough@2.2.0: {} - tsdown@0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@5.9.3): + tsdown@0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3): dependencies: ansis: 4.4.0 cac: 6.7.14 @@ -8044,8 +8230,8 @@ snapshots: import-without-cache: 0.2.5 obug: 2.2.1 picomatch: 4.0.7 - rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@5.9.3) + 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(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17 @@ -8063,7 +8249,7 @@ snapshots: - synckit - vue-tsc - tsdown@0.20.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3)(typescript@6.0.3): + tsdown@0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@6.0.3): dependencies: ansis: 4.4.0 cac: 6.7.14 @@ -8073,8 +8259,8 @@ snapshots: import-without-cache: 0.2.5 obug: 2.2.1 picomatch: 4.0.7 - rolldown: 1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.11.3))(typescript@6.0.3) + 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) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17 From 246686140966f383e306bdb9f9bf00af1630b300 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:12:29 +0200 Subject: [PATCH 3/9] test(content-freshness): take the audit night from the real clock The full-audit test pinned the host's scheduler clock to 04:00:05 on 2026-09-25, but the plugin schedules its follow-up runs from `Date.now()`. While that night lay ahead of the real time, follow-ups fell due; since then they land after the host's clock, never run, and the sweep never finishes. The test now uses tomorrow's night, which is always ahead of the wall clock. It still fails if a follow-up is lost. --- .../tests/full-audit.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 packages/content-freshness/tests/full-audit.test.ts diff --git a/packages/content-freshness/tests/full-audit.test.ts b/packages/content-freshness/tests/full-audit.test.ts new file mode 100644 index 0000000..c50b4e8 --- /dev/null +++ b/packages/content-freshness/tests/full-audit.test.ts @@ -0,0 +1,44 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +/** + * 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:lastSweepFinishedAt")).not.toBeNull(); + }); +}); From 3ab9e6bf3d49678d3ac6244af9de29e209fc5cfb Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:17:17 +0200 Subject: [PATCH 4/9] chore(content-freshness): test against EmDash 0.40.1 plugin-test 0.2.3 pins EmDash 0.40.1, so the suite's baseline moves from 0.39.1 to the current release. The peer floor stays at 0.39.0: every API the plugin uses exists there. `shared` takes its EmDash types from its own dev dependency. Left on 0.39.1, its `PluginContext` rejected the plugin's 0.40.1 one, because 0.40 added the `blocks` field type, so it resolves 0.40.1 as well. `shared` imports EmDash for types only; analytics keeps its own toolchain and still typechecks. pnpm added the 0.40.1 packages to minimumReleaseAgeExclude itself, as it did for 0.39.0. --- packages/content-freshness/AGENTS.md | 2 +- packages/content-freshness/package.json | 2 +- pnpm-lock.yaml | 266 ++++++++++++++++++++++-- pnpm-workspace.yaml | 14 +- 4 files changed, 260 insertions(+), 24 deletions(-) diff --git a/packages/content-freshness/AGENTS.md b/packages/content-freshness/AGENTS.md index d709ee5..e63c328 100644 --- a/packages/content-freshness/AGENTS.md +++ b/packages/content-freshness/AGENTS.md @@ -8,7 +8,7 @@ Keep `emdash-plugin.jsonc` aligned with the runtime implementation, declare ever `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.1`; +`@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/package.json b/packages/content-freshness/package.json index 6dc7266..e722b09 100644 --- a/packages/content-freshness/package.json +++ b/packages/content-freshness/package.json @@ -54,7 +54,7 @@ "devDependencies": { "@eisbachcode/emdash-plugin-shared": "workspace:*", "@emdash-cms/plugin-cli": "0.12.0", - "@emdash-cms/plugin-test": "0.2.1", + "@emdash-cms/plugin-test": "0.2.3", "emdash": ">=0.39.0 <1.0.0", "typescript": "^5.9.0", "vitest": "^4.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a27f969..34050f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,11 +48,11 @@ importers: 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.1 - version: 0.2.1(f64bdd591a184e04878ebed7d065ed68) + specifier: 0.2.3 + version: 0.2.3(f64bdd591a184e04878ebed7d065ed68) 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) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -64,7 +64,7 @@ importers: devDependencies: 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) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -589,6 +589,12 @@ packages: 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: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@emdash-cms/auth@0.39.0': resolution: {integrity: sha512-13LmRSfkgdAl3NCpzd7Fsl1WSXmaGDiIAooaOXs5O/hSocaP3BQR59N1tLWT1Ibkd0dz2rzIuibFIu+fGHH+8A==} peerDependencies: @@ -607,6 +613,15 @@ packages: kysely: optional: true + '@emdash-cms/auth@0.40.1': + resolution: {integrity: sha512-ie6/7udv7bKBrBfIRSyfdT8vX0J7wNiq+Xcq/1A03ZR0rWtnYCEaPQzENlDv8jnZ/I7ypbCo6Q2ukuiKTZTkfg==} + peerDependencies: + astro: '>=6.0.0-beta.0' + kysely: ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + '@emdash-cms/blocks@0.39.0': resolution: {integrity: sha512-rXUAvjhxVJflkXdz4mW210TMC7zS5ueGd7BxTOhPKGVUiDNsWKzTwaOrPTP/wDX1JdYZbm8xeeRgGclZSHyqkA==} peerDependencies: @@ -619,6 +634,12 @@ packages: 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: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@emdash-cms/cloudflare@0.39.0': resolution: {integrity: sha512-srdTVyTiI4RRdPfRKgryaX1af3tmNNyMn33yP/XAYet7jNMH+feBr6TNhAHNeLXTHqLD00lmRJ8hyDDyLoFApA==} peerDependencies: @@ -641,8 +662,8 @@ packages: react: optional: true - '@emdash-cms/cloudflare@0.39.1': - resolution: {integrity: sha512-Hx2ZABfc5MBUwC8vVvHPGk+snQ1JP28+g8M7+vjxyWOEqBIlRU+y3hPGFKy7Nttbqf8fv1MCa1SHcojSBf6Evw==} + '@emdash-cms/cloudflare@0.40.1': + resolution: {integrity: sha512-cA7dTRw0X0H89To8D6VWBafdHBCsFxmFcTqhqgx7znH1Jj6XPE6V2JNdK31vonZkHBBf8BGvqQAfT+y31k/KTw==} peerDependencies: '@astrojs/cloudflare': '>=12.0.0' '@cloudflare/kumo': 2.6.0 @@ -669,6 +690,9 @@ packages: '@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==} + '@emdash-cms/plugin-cli@0.12.0': resolution: {integrity: sha512-p8nYyx4IiaBe3p6mWiFzgdraql42+bWVlyLe0vh0fTPVkyVdXzhf+nkrd1amltDjIs1r7uvHxAT94JPRK4ih1Q==} hasBin: true @@ -679,8 +703,8 @@ packages: vite: ^8.0.0 vitest: ^4.1.0 - '@emdash-cms/plugin-test@0.2.1': - resolution: {integrity: sha512-uuzobdT9Vw+8yUxlkI3uWYI/8gZuLiMA6MBNb590SvZ3clRLzLXvck6fskoiFsngAyDgO6SLK5tT2YTIMNr7TQ==} + '@emdash-cms/plugin-test@0.2.3': + resolution: {integrity: sha512-DY+moUl3DZUu1s1rM2BL9kSrsAr+KpS9jvP/1De57qhK1y8kH3OAeexYGh7vfoyNWG8H357P50k7sR8BI+Jvmg==} peerDependencies: vite: ^8.0.0 vitest: ^4.1.0 @@ -2558,6 +2582,20 @@ packages: '@emdash-cms/auth-atproto': optional: true + emdash@0.40.1: + resolution: {integrity: sha512-26xb74bpyQR0BTt0NWRuzWbAnQ9XmmKnRX+W/z2Q+l6e0Fh6Y6uV/rFa7S7yEGvo8Wj7HhbKFoTKYLpFOkdgfQ==} + engines: {node: '>=22.16'} + hasBin: true + peerDependencies: + '@astrojs/react': '>=5.0.0-beta.0' + '@emdash-cms/auth-atproto': '>=0.2.8' + astro: '>=6.0.0-beta.0' + react: '>=18.0.0' + react-dom: '>=18.0.0' + peerDependenciesMeta: + '@emdash-cms/auth-atproto': + optional: true + empathic@2.1.0: resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} engines: {node: '>=14'} @@ -4868,6 +4906,84 @@ snapshots: - 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 + '@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.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/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 + modern-tar: 0.7.7 + 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' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - date-fns + - echarts + - prosemirror-model + - prosemirror-state + - prosemirror-view + - typescript + - zod + '@emdash-cms/auth@0.39.0(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': dependencies: '@oslojs/crypto': 1.0.1 @@ -4890,6 +5006,17 @@ snapshots: 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 + '@oslojs/encoding': 1.1.0 + '@oslojs/webauthn': 1.0.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + ulidx: 2.4.1 + zod: 4.5.4 + optionalDependencies: + kysely: 0.29.6 + '@emdash-cms/blocks@0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': dependencies: '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) @@ -4922,7 +5049,23 @@ snapshots: - 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.6.5)': + '@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) + '@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.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) @@ -4982,13 +5125,13 @@ snapshots: - typescript - utf-8-validate - '@emdash-cms/cloudflare@0.39.1(4393bffb26115eecd490f095bbbbcb1e)': + '@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.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) + 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) @@ -5036,6 +5179,11 @@ snapshots: '@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 + parse5: 7.3.0 + '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) @@ -5137,14 +5285,14 @@ snapshots: - wrangler - zod - '@emdash-cms/plugin-test@0.2.1(f64bdd591a184e04878ebed7d065ed68)': + '@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.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.6.5) - '@emdash-cms/cloudflare': 0.39.1(4393bffb26115eecd490f095bbbbcb1e) + '@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.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) + 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)) @@ -7011,6 +7159,94 @@ snapshots: - 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) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@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) + '@emdash-cms/auth': 0.40.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6) + '@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/gutenberg-to-portable-text': 0.40.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@modelcontextprotocol/sdk': 1.30.1(supports-color@10.2.2)(zod@4.5.4) + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@portabletext/toolkit': 5.0.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@unpic/placeholder': 0.1.2 + arctic: 3.7.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + astro-portabletext: 0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)) + blurhash: 2.0.5 + citty: 0.2.2 + consola: 3.4.2 + croner: 10.0.1 + highlight.js: 11.11.2 + jose: 6.2.12 + jpeg-js: 0.4.4 + jsonc-parser: 3.3.1 + kysely: 0.29.6 + lowlight: 3.3.0 + mime: 4.1.0 + modern-tar: 0.7.7 + picocolors: 1.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + sanitize-html: 2.17.7 + sax: 1.6.1 + smol-toml: 1.9.0 + ulidx: 2.4.1 + upng-js: 2.1.0 + zod: 4.5.4 + optionalDependencies: + '@libsql/kysely-libsql': 0.4.1(kysely@0.29.6) + pg: 8.23.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + - utf-8-validate + empathic@2.1.0: {} encodeurl@2.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 842183a..4cdda1f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -21,15 +21,15 @@ allowBuilds: strictDepBuilds: true dangerouslyAllowAllBuilds: false minimumReleaseAgeExclude: - - '@emdash-cms/admin@0.39.0' - - '@emdash-cms/auth@0.39.0' - - '@emdash-cms/blocks@0.39.0' - - '@emdash-cms/cloudflare@0.39.0' - - '@emdash-cms/gutenberg-to-portable-text@0.39.0' + - '@emdash-cms/admin@0.39.0 || 0.40.1' + - '@emdash-cms/auth@0.39.0 || 0.40.1' + - '@emdash-cms/blocks@0.39.0 || 0.40.1' + - '@emdash-cms/cloudflare@0.39.0 || 0.40.1' + - '@emdash-cms/gutenberg-to-portable-text@0.39.0 || 0.40.1' - '@emdash-cms/plugin-cli@0.12.0' - - '@emdash-cms/plugin-test@0.2.0' + - '@emdash-cms/plugin-test@0.2.0 || 0.2.3' - '@emdash-cms/plugin-types@0.4.0' - '@emdash-cms/registry-client@0.6.1' - '@emdash-cms/registry-lexicons@0.6.0' - '@emdash-cms/registry-verification@0.3.2' - - emdash@0.39.0 + - emdash@0.39.0 || 0.40.1 From 9cc13d1e16ce2735a45663ef73548946c20608c3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:26:24 +0200 Subject: [PATCH 5/9] docs(content-freshness): tested on every EmDash release from 0.39.0 to 0.40.1 --- packages/content-freshness/README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 902ef97..365fdcc 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -82,16 +82,18 @@ pnpm build | EmDash | Plugin test suite | | ------ | ----------------- | +| 0.40.1 | 38 passed | +| 0.40.0 | 38 passed | | 0.39.1 | 38 passed | | 0.39.0 | 38 passed | -Declared range: `emdash >=0.39.0 <1.0.0`. The table covers **every released -version inside it** — 0.39.0 and 0.39.1 are the only ones published, 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. +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-24, when 0.39.1 was the latest EmDash release. A newer EmDash than the +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. From 4a2ade461cade40db38098a1cfb78fd7972e7a0b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:26:39 +0200 Subject: [PATCH 6/9] ci: test the 0.39.0 floor explicitly The gating job ran each suite on its harness's own EmDash plus 0.39.1 and 0.40.0. That covered the floor only while every harness pinned 0.39.0. content-freshness now runs on plugin-test 0.2.3, which pins 0.40.1, so 0.39.0, the floor both plugins declare, joins the list. --- .github/workflows/compat-matrix.yml | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compat-matrix.yml b/.github/workflows/compat-matrix.yml index 73c6bf6..d3df035 100644 --- a/.github/workflows/compat-matrix.yml +++ b/.github/workflows/compat-matrix.yml @@ -41,7 +41,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck - - run: ./scripts/compat-matrix.sh 0.39.1 0.40.0 + - run: ./scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 latest: name: current EmDash (informational) diff --git a/AGENTS.md b/AGENTS.md index f2231c5..1a913e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ pnpm install pnpm typecheck pnpm test # emdash-plugin validate, then vitest, per package pnpm build -./scripts/compat-matrix.sh 0.39.1 0.40.0 # suites against other EmDash releases +./scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 # suites against other EmDash releases ``` ## Releases From 4ce4dda0d71104dd5e7ea475b693e53e0d992a56 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:47:53 +0200 Subject: [PATCH 7/9] fix(content-freshness): works sandboxed, pages its report, forgets deleted entries Found while preparing the first release; the plugin had only ever run in-process. - Sandboxed, every admin surface with findings answered 502: `stats` used `stats` instead of `items`, the table had no `page_action_id`, and the settings form sent `elements` instead of `fields` and `submit`. Blocks are now typed with @emdash-cms/blocks (types only) and the tests load every surface through the sandboxed host, which validates them. - A cron run made 16 bridge calls against the sandbox's 10: seven KV reads for settings, one per collection cursor, a `cron.list`, and a log line. Settings and sweep state are one KV key each now, a start while a sweep runs continues it instead of checking the task list, and a run makes 6 to 8 calls. A budget test counts every invocation. - The report asked storage for 200 rows, got at most 100 and sorted those, so an urgent finding could be missing from the table while the stats counted it. Findings are one row per entry with an indexed rank, and the table pages through them most urgent first. - Rows of trashed or deleted entries, and of removed collections, stayed forever. A finished sweep removes every row it did not confirm, and `content:afterDelete` removes an entry's row at once. - A fix showed only after the next nightly audit. Publishing, unpublishing, scheduling, unscheduling and restoring re-check the entry. `content:afterSave` stays unhooked because it fires on every autosave. - A missed scheduled update of a published entry went unreported; the rule required an unpublished entry. It now covers both, after an hour of grace for EmDash's own scheduler. - `plugin:activate` never fires for a plugin listed in astro.config.mjs, so the nightly audit was never scheduled there. The dashboard widget and the report schedule it on first view. - "Entries per run" accepted 200 where `content.list` returns 100, and an invalid cron expression was stored and failed later; the form now keeps the old schedule and says why. Findings store a rule and its values instead of an English sentence, so the report can be localised without touching stored data. --- packages/content-freshness/README.md | 22 +- .../content-freshness/emdash-plugin.jsonc | 7 +- packages/content-freshness/package.json | 1 + packages/content-freshness/src/describe.ts | 25 ++ packages/content-freshness/src/findings.ts | 73 ++++ packages/content-freshness/src/hooks.ts | 51 +++ packages/content-freshness/src/plugin.ts | 205 ++++++---- packages/content-freshness/src/report.ts | 318 ++++++++------- packages/content-freshness/src/rules.ts | 104 +++-- packages/content-freshness/src/settings.ts | 87 ++--- packages/content-freshness/src/state.ts | 57 +++ packages/content-freshness/src/sweep.ts | 164 +++++--- .../content-freshness/tests/audit.test.ts | 105 +++++ .../content-freshness/tests/bridge-calls.ts | 55 +++ .../content-freshness/tests/budget.test.ts | 174 +++++++++ .../tests/cron-names.test.ts | 4 +- .../tests/full-audit.test.ts | 4 +- packages/content-freshness/tests/host.ts | 47 +++ .../tests/permissions.test.ts | 2 +- .../content-freshness/tests/plugin.test.ts | 11 +- .../tests/report-paging.test.ts | 59 +++ .../content-freshness/tests/report.test.ts | 62 +-- .../content-freshness/tests/routing.test.ts | 19 +- .../content-freshness/tests/rules.test.ts | 46 ++- .../content-freshness/tests/settings.test.ts | 49 ++- .../content-freshness/tests/surfaces.test.ts | 90 +++++ .../content-freshness/tests/sweep.test.ts | 93 ----- pnpm-lock.yaml | 362 +++++++++++++++++- 28 files changed, 1716 insertions(+), 580 deletions(-) create mode 100644 packages/content-freshness/src/describe.ts create mode 100644 packages/content-freshness/src/findings.ts create mode 100644 packages/content-freshness/src/hooks.ts create mode 100644 packages/content-freshness/src/state.ts create mode 100644 packages/content-freshness/tests/audit.test.ts create mode 100644 packages/content-freshness/tests/bridge-calls.ts create mode 100644 packages/content-freshness/tests/budget.test.ts create mode 100644 packages/content-freshness/tests/host.ts create mode 100644 packages/content-freshness/tests/report-paging.test.ts create mode 100644 packages/content-freshness/tests/surfaces.test.ts delete mode 100644 packages/content-freshness/tests/sweep.test.ts diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 365fdcc..61b5662 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -7,7 +7,7 @@ What it looks for: | Finding | Priority | | --- | --- | -| Scheduled to publish, the date passed, still not published | urgent | +| 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 | | SEO description too short or too long | nice to fix | @@ -20,12 +20,16 @@ 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. -A fixed entry drops out of the report on the next sweep — the audit stores the -current state, not a history. +The report holds one row per entry that needs attention, most urgent first. +It shows the current state, not a history: an entry is checked again when it +is published, unpublished, scheduled, unscheduled or restored, and leaves the +report as soon as it is trashed or deleted. Other edits show up with the next +audit, which also removes rows of entries that no longer exist. 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. +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 @@ -51,8 +55,10 @@ emdash({ plugins: [contentFreshness] }); Or under `sandboxed: []` if the site has a sandbox runner configured. -Then activate it once under **Admin → Extensions**. Every collection on the -site is audited. +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 @@ -66,8 +72,8 @@ entry. Without one, the audit is scheduled and never runs. | Draft forgotten after | 6 months | For drafts. | | 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 | One collection list, one `content.list` and two batched storage calls per run, whatever the page size. | -| Schedule | `0 4 * * *` | Any cron expression. | +| Entries per run | 50 | At most 100, the most one content query returns. 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. | ## Develop diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc index 48a500c..39a285c 100644 --- a/packages/content-freshness/emdash-plugin.jsonc +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -30,9 +30,10 @@ "allowedHosts": [], "storage": { - // One row per (entry, rule). Re-running overwrites, so a fixed - // entry drops out of the report on the next sweep. - "findings": { "indexes": ["collection", "severity", "rule", "entryId"] }, + // 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"] }, }, "admin": { diff --git a/packages/content-freshness/package.json b/packages/content-freshness/package.json index e722b09..5358dc7 100644 --- a/packages/content-freshness/package.json +++ b/packages/content-freshness/package.json @@ -53,6 +53,7 @@ }, "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", diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts new file mode 100644 index 0000000..8fc87af --- /dev/null +++ b/packages/content-freshness/src/describe.ts @@ -0,0 +1,25 @@ +/** + * The sentence the report shows for a hit, built from its stored params. + */ + +import type { Hit } from "./rules.js"; + +export function describeHit(hit: Hit): string { + const { params } = hit; + switch (hit.rule) { + case "overdue-schedule": + return params.kind === "update" + ? `Changes scheduled for ${params.date} were never published.` + : `Scheduled for ${params.date} but still ${params.status}.`; + case "missing-description": + return params.field + ? `No SEO description; templates often show the "${params.field}" field instead.` + : "Published without an SEO description."; + case "description-length": + return `SEO description is ${params.length} characters; aim for ${params.min}–${params.max}.`; + case "stale": + return `Not touched since ${params.since}.`; + case "stale-draft": + return `Draft untouched since ${params.since}.`; + } +} diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts new file mode 100644 index 0000000..9fd6318 --- /dev/null +++ b/packages/content-freshness/src/findings.ts @@ -0,0 +1,73 @@ +/** + * 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 { evaluateEntry, worstRank, type Hit, type Thresholds } from "./rules.js"; + +export interface EntryFindings { + collection: string; + entryId: string; + slug: string | null; + locale: 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 function findingId(collection: string, entryId: string): string { + return `${collection}:${entryId}`; +} + +export interface Evaluated { + writes: Array<{ id: string; data: EntryFindings }>; + /** Entries evaluated clean, whose rows must go. */ + clears: string[]; +} + +export function evaluateEntries( + collection: string, + entries: PluginContentItem[], + thresholds: Thresholds, + now: Date, + seenIn: string, +): Evaluated { + const result: Evaluated = { writes: [], clears: [] }; + for (const entry of entries) { + const id = findingId(collection, entry.id); + const hits = evaluateEntry(entry, thresholds, now); + if (hits.length === 0) { + result.clears.push(id); + continue; + } + result.writes.push({ + id, + data: { + collection, + entryId: entry.id, + slug: entry.slug, + locale: entry.locale ?? null, + rank: worstRank(hits), + hits, + entryUpdatedAt: entry.updatedAt, + seenIn, + }, + }); + } + return result; +} + +/** 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); +} diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts new file mode 100644 index 0000000..64eba5b --- /dev/null +++ b/packages/content-freshness/src/hooks.ts @@ -0,0 +1,51 @@ +/** + * 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 by the next sweep. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { evaluateEntries, findingId, store } from "./findings.js"; +import { readSettings } from "./settings.js"; + +interface EntryRef { + collection: string; + id: string; +} + +/** 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 }`. */ +export function deletedEntry(event: unknown): EntryRef | null { + if (typeof event !== "object" || event === null) return null; + const { id, collection } = event as { id?: unknown; collection?: unknown }; + return typeof id === "string" && typeof collection === "string" ? { collection, id } : null; +} + +/** Evaluate one entry now and store the result. Three `ctx` calls. */ +export async function reevaluate(ctx: PluginContext, ref: EntryRef): Promise { + if (!ctx.content) return; + const [settings, entry] = await Promise.all([readSettings(ctx), ctx.content.get(ref.collection, ref.id)]); + if (!entry) { + await forget(ctx, ref); + return; + } + const now = new Date(); + await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString())); +} + +export async function forget(ctx: PluginContext, ref: EntryRef): Promise { + await ctx.storage.findings.delete(findingId(ref.collection, ref.id)); +} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index 74fb7c8..f9aee0e 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -5,86 +5,98 @@ * `plugins: []`) and sandboxed mode. Logic lives in the sibling modules. */ +import { hasRole, ROLE } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; -import { buildReportPage, buildSettingsPage, buildWidget, countFindings } from "./report.js"; +import { changedEntry, deletedEntry, forget, reevaluate } from "./hooks.js"; import { - followUpPending, - hasRole, - isFollowUp, - listCollections, - ROLE, - scheduleFollowUp, - type SweepChain, -} from "@eisbachcode/emdash-plugin-shared"; -import { readSettings, writeSettings } from "./settings.js"; -import { auditNextPage, finishSweep } from "./sweep.js"; - -async function collectionSlugs(ctx: PluginContext): Promise { - return (await listCollections(ctx)).map((collection) => collection.slug); -} - -/** Audit the next page of the next unfinished collection. Returns whether there is more to do. */ -async function auditStep(ctx: PluginContext): Promise { - const settings = await readSettings(ctx); - const collections = await collectionSlugs(ctx); - if (collections.length === 0) { - ctx.log.warn("No collections to audit"); - return false; - } - - const result = await auditNextPage(ctx, settings, collections); - if (!result) { - await finishSweep(ctx, collections); - ctx.log.info("Freshness audit complete"); - return false; - } - ctx.log.info(`Audited ${result.entries} entries in ${result.collection}, ${result.found} findings`); - return true; -} + AUDIT_NOW_ACTION, + buildReportPage, + buildSettingsPage, + buildWidget, + countFindings, + FIRST_PAGE_ACTION, + PAGE_ACTION, + SAVE_SETTINGS_ACTION, +} from "./report.js"; +import { applyForm, readSettings, SETTINGS_KEY } from "./settings.js"; +import { ensureScheduled, readState, writeState } from "./state.js"; +import { AUDIT_NOW_TASK, runAudit } from "./sweep.js"; interface AdminInteraction { type: string; page?: string; action_id?: string; + value?: unknown; values?: Record; } -/** - * The recurring task, and the one-shot the "Audit 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 recurring audit with a task that runs once and never again. The - * button schedules its own name instead, and the cron handler answers to - * both. - */ -const AUDIT_TASK = "audit"; -const AUDIT_NOW_TASK = "audit-now"; +/** Settings and state, with the recurring audit registered if it was not yet. */ +async function loadAdmin(ctx: PluginContext) { + const [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); + if (await ensureScheduled(ctx, settings, state)) await writeState(ctx, state); + return { settings, state }; +} + +function cursorOf(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const cursor = (value as { cursor?: unknown }).cursor; + return typeof cursor === "string" && cursor ? cursor : undefined; +} + +/** 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 reevaluate(ctx, ref); + } catch (error) { + ctx.log.warn(`Could not re-check ${ref.collection}/${ref.id}: ${String(error)}`); + } +} -/** An audit carries on in follow-up runs until it is done. 500 runs is 25,000 entries at the default page size. */ -const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSteps: 500 }; +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 settings = await readSettings(ctx); - await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); - ctx.log.info(`Freshness audit scheduled: ${settings.schedule}`); + await loadAdmin(ctx); }, }, + /** + * 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) => { - const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; - if (!starts && !isFollowUp(CHAIN, event.name)) return; - // A follow-up already waiting means an audit is under way. - if (starts && (await followUpPending(ctx, CHAIN))) return; - - if (!(await auditStep(ctx))) return; - if (!(await scheduleFollowUp(ctx, CHAIN, event))) { - ctx.log.warn(`Audit paused after ${CHAIN.maxSteps} runs; it continues at the next scheduled run`); + 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); + } catch (error) { + ctx.log.warn(`Could not drop ${ref.collection}/${ref.id}: ${String(error)}`); } }, }, @@ -103,29 +115,36 @@ const plugin = { const interaction = routeCtx.input as AdminInteraction; if (interaction.type === "page_load") { - if (interaction.page === "widget:summary") return buildWidget(ctx); - if (interaction.page === "/settings") return buildSettingsPage(ctx); - return buildReportPage(ctx); + const { settings, state } = await loadAdmin(ctx); + if (interaction.page === "widget:summary") return buildWidget(ctx, state); + if (interaction.page === "/settings") return buildSettingsPage(settings); + return buildReportPage(ctx, state); } - if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { - if (!hasRole(routeCtx.user, ROLE.ADMIN)) { + if (interaction.type === "form_submit" && interaction.action_id === SAVE_SETTINGS_ACTION) { + return saveSettings(ctx, routeCtx.user, interaction.values ?? {}); + } + + if (interaction.type === "block_action") { + if (interaction.action_id === PAGE_ACTION) { + return buildReportPage(ctx, await readState(ctx), cursorOf(interaction.value)); + } + if (interaction.action_id === FIRST_PAGE_ACTION) { + return buildReportPage(ctx, await readState(ctx)); + } + if (interaction.action_id === AUDIT_NOW_ACTION) { + await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); + const state = await readState(ctx); return { - ...(await buildSettingsPage(ctx)), - toast: { message: "Only an administrator can change these settings.", type: "error" }, + ...(await buildReportPage(ctx, state)), + toast: { + message: state.sweep + ? "The running audit continues with the next scheduled run." + : "The audit starts with the next scheduled run.", + type: "info", + }, }; } - await writeSettings(ctx, interaction.values ?? {}); - const settings = await readSettings(ctx); - // Re-register so a changed cron expression takes effect. - await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); - return buildSettingsPage(ctx); - } - - if (interaction.type === "block_action" && interaction.action_id === "audit_now") { - await finishSweep(ctx, await collectionSlugs(ctx)); - await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); - return buildReportPage(ctx); } return { blocks: [] }; @@ -134,16 +153,44 @@ const plugin = { status: { handler: async (_routeCtx, ctx) => { + const [state, counts] = await Promise.all([readState(ctx), countFindings(ctx)]); return { - collections: await collectionSlugs(ctx), - lastSweepFinishedAt: await ctx.kv.get("state:lastSweepFinishedAt"), - counts: await countFindings(ctx), + lastSweepFinishedAt: state.lastFinishedAt, + sweepInProgress: state.sweep !== null, + counts, }; }, }, }, } satisfies SandboxedPlugin; +async function saveSettings( + ctx: PluginContext, + user: Parameters[0], + values: Record, +) { + const [current, state] = await Promise.all([readSettings(ctx), readState(ctx)]); + if (!hasRole(user, ROLE.ADMIN)) { + return { + ...buildSettingsPage(current), + toast: { message: "Only an administrator can change these settings.", type: "error" as const }, + }; + } + + const next = applyForm(current, values); + // Schedule first: an expression the scheduler rejects is never stored. + try { + await ensureScheduled(ctx, next, state); + } catch { + return { + ...buildSettingsPage(current), + toast: { message: `"${next.schedule}" is not a cron expression the scheduler accepts.`, type: "error" as const }, + }; + } + await Promise.all([ctx.kv.set(SETTINGS_KEY, next), writeState(ctx, state)]); + return { ...buildSettingsPage(next), toast: { message: "Settings 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 diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index fa744fe..efc798f 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -1,37 +1,73 @@ /** * 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, + FormBlock, + StatsBlock, + TableBlock, +} from "@emdash-cms/blocks"; import { listCollections } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import { describeHit } from "./describe.js"; +import type { EntryFindings } from "./findings.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; -import { readSettings } from "./settings.js"; -import type { StoredFinding } from "./sweep.js"; +import type { Settings } from "./settings.js"; +import type { State } from "./state.js"; -type Block = Record; +export const PAGE_ACTION = "findings_page"; +export const FIRST_PAGE_ACTION = "findings_first_page"; +export const AUDIT_NOW_ACTION = "audit_now"; +export const SAVE_SETTINGS_ACTION = "save_settings"; -const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 } as const; +/** Rows per report page. Storage queries return at most 100. */ +const PAGE_ROWS = 50; + +const PRIORITY = ["Urgent", "Should fix", "Nice to fix"] as const; export interface Counts { - high: number; - medium: number; - low: number; + /** Entries by their most urgent finding: index 0 urgent, 1 should fix, 2 nice to fix. */ + byRank: [number, number, number]; total: number; } export async function countFindings(ctx: PluginContext): Promise { - const [high, medium, low, total] = await Promise.all([ - ctx.storage.findings.count({ severity: "high" }), - ctx.storage.findings.count({ severity: "medium" }), - ctx.storage.findings.count({ severity: "low" }), - ctx.storage.findings.count(), - ]); - return { high, medium, low, total }; + const byRank = (await Promise.all([0, 1, 2].map((rank) => ctx.storage.findings.count({ rank })))) as [ + number, + number, + number, + ]; + return { byRank, total: byRank[0] + byRank[1] + byRank[2] }; +} + +function statsBlock(counts: Counts, withTotal: boolean): StatsBlock { + return { + type: "stats", + items: [ + ...PRIORITY.map((label, rank) => ({ label, value: counts.byRank[rank] ?? 0 })), + ...(withTotal ? [{ label: "Entries", value: counts.total }] : []), + ], + }; +} + +function auditStatus(state: State): string { + if (state.sweep) return `Audit in progress since ${state.sweep.startedAt.slice(0, 10)}`; + if (state.lastFinishedAt) return `Last audit ${state.lastFinishedAt.slice(0, 10)}`; + return "First audit pending"; } /** A warning about collections whose public URLs EmDash gets wrong, or nothing. */ -async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { +async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { const missing = collectionsWithoutUrlPattern(await listCollections(ctx)); if (missing.length === 0) return []; const names = missing.map((collection) => collection.label || collection.slug).join(", "); @@ -49,10 +85,8 @@ async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { - const counts = await countFindings(ctx); - const finished = await ctx.kv.get("state:lastSweepFinishedAt"); - const banner = await urlPatternBanner(ctx, false); +export async function buildWidget(ctx: PluginContext, state: State): Promise { + const [counts, banner] = await Promise.all([countFindings(ctx), urlPatternBanner(ctx, false)]); if (counts.total === 0) { if (banner.length > 0) return { blocks: banner }; @@ -60,163 +94,165 @@ export async function buildWidget(ctx: PluginContext): Promise<{ blocks: Block[] blocks: [ { type: "empty", - icon: "check", - title: finished ? "Everything current" : "First audit pending", - description: finished ? `Last audit ${finished.slice(0, 10)}.` : "Runs on the next scheduled sweep.", + title: state.lastFinishedAt ? "Everything current" : "First audit pending", + description: state.lastFinishedAt + ? `Last audit ${state.lastFinishedAt.slice(0, 10)}.` + : "Runs on the next scheduled audit.", }, ], }; } return { - blocks: [ - ...banner, - { - type: "stats", - stats: [ - { label: "Urgent", value: String(counts.high) }, - { label: "Should fix", value: String(counts.medium) }, - { label: "Nice to fix", value: String(counts.low) }, - ], - }, - { type: "context", text: finished ? `Last audit ${finished.slice(0, 10)}` : "Audit in progress" }, - ], + blocks: [...banner, statsBlock(counts, false), { type: "context", text: auditStatus(state) }], }; } -export async function buildReportPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { - const banner = await urlPatternBanner(ctx, true); - const counts = await countFindings(ctx); - const stored = await ctx.storage.findings.query({ limit: 200 }); - const rows = stored.items - .map(({ data }) => data as StoredFinding) - .sort( - (a, b) => - SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || - a.collection.localeCompare(b.collection) || - (a.slug ?? a.entryId).localeCompare(b.slug ?? b.entryId), - ); +/** + * A page of rows, most urgent first, and whether it is a later page. A + * cursor storage rejects falls back to the first page. + */ +async function findingsPage(ctx: PluginContext, cursor: string | undefined) { + const query = { orderBy: { rank: "asc" as const }, limit: PAGE_ROWS }; + if (cursor) { + try { + return { page: await ctx.storage.findings.query({ ...query, cursor }), later: true }; + } catch { + // A cursor from the client that storage cannot decode. + } + } + return { page: await ctx.storage.findings.query(query), later: false }; +} - const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; +export async function buildReportPage( + ctx: PluginContext, + state: State, + cursor?: string, +): Promise { + const [banner, counts, { page, later }] = await Promise.all([ + urlPatternBanner(ctx, true), + countFindings(ctx), + findingsPage(ctx, cursor), + ]); + const auditNow: ActionsBlock = { + type: "actions", + elements: [ + { type: "button", action_id: AUDIT_NOW_ACTION, label: "Audit now" }, + ...(later ? [{ type: "button" as const, action_id: FIRST_PAGE_ACTION, label: "First page" }] : []), + ], + }; - if (rows.length === 0) { + if (counts.total === 0) { return { blocks: [ { type: "header", text: "Content freshness" }, ...banner, { type: "empty", - icon: "check", title: "Nothing needs attention", description: "No stale entries, no missing descriptions, no overdue schedules.", }, + { type: "context", text: auditStatus(state) }, + auditNow, ], }; } - return { - blocks: [ - { type: "header", text: "Content freshness" }, - ...banner, + const rows = page.items.map(({ data }) => data as EntryFindings); + const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; + const table: TableBlock = { + type: "table", + page_action_id: PAGE_ACTION, + columns: [ + { key: "priority", label: "Priority", format: "badge" }, + { key: "entry", label: "Entry" }, + ...(multilingual ? [{ key: "locale", label: "Language" }] : []), + { key: "collection", label: "Collection" }, + { key: "findings", label: "Findings" }, + { key: "updated", label: "Last touched" }, + ], + rows: rows.map((row) => ({ + priority: PRIORITY[row.rank] ?? "", + entry: row.slug ?? row.entryId, + locale: row.locale ?? "", + collection: row.collection, + findings: row.hits.map(describeHit).join(" "), + updated: row.entryUpdatedAt.slice(0, 10), + })), + ...(page.hasMore && page.cursor ? { next_cursor: page.cursor } : {}), + }; + + const blocks: Block[] = [ + { type: "header", text: "Content freshness" }, + ...banner, + statsBlock(counts, true), + { type: "context", text: auditStatus(state) }, + table, + auditNow, + ]; + return { blocks }; +} + +export function buildSettingsPage(settings: Settings): BlockResponse { + const form: FormBlock = { + type: "form", + fields: [ + { + type: "number_input", + action_id: "staleMonths", + label: "Months before a published entry counts as stale", + min: 1, + max: 120, + initial_value: settings.staleMonths, + }, + { + type: "number_input", + action_id: "draftMonths", + label: "Months before a draft counts as forgotten", + min: 1, + max: 120, + initial_value: settings.draftMonths, + }, { - type: "stats", - stats: [ - { label: "Urgent", value: String(counts.high) }, - { label: "Should fix", value: String(counts.medium) }, - { label: "Nice to fix", value: String(counts.low) }, - { label: "Total", value: String(counts.total) }, - ], + type: "number_input", + action_id: "descriptionMin", + label: "Shortest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMin, }, { - type: "table", - columns: [ - { key: "severity", label: "Priority" }, - { key: "entry", label: "Entry" }, - ...(multilingual ? [{ key: "locale", label: "Language" }] : []), - { key: "collection", label: "Collection" }, - { key: "detail", label: "Finding" }, - { key: "updated", label: "Last touched" }, - ], - rows: rows.map((row) => ({ - severity: row.severity, - entry: row.slug ?? row.entryId, - locale: row.locale ?? "", - collection: row.collection, - detail: row.detail, - updated: row.entryUpdatedAt.slice(0, 10), - })), + type: "number_input", + action_id: "descriptionMax", + label: "Longest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMax, }, { - type: "actions", - elements: [{ type: "button", action_id: "audit_now", label: "Re-run the audit now" }], + type: "toggle", + action_id: "reportMissingDescriptions", + label: "Report entries without an SEO description", + description: "Switch off if your templates always render a description of their own.", + initial_value: settings.reportMissingDescriptions, }, + { + type: "number_input", + action_id: "pageSize", + label: "Entries per run", + min: 1, + max: 100, + initial_value: settings.pageSize, + }, + { type: "text_input", action_id: "schedule", label: "Schedule (cron, UTC)", initial_value: settings.schedule }, ], + submit: { label: "Save", action_id: SAVE_SETTINGS_ACTION }, }; -} - -export async function buildSettingsPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { - const settings = await readSettings(ctx); return { blocks: [ { type: "header", text: "Freshness settings" }, - { - type: "context", - text: "Every collection on the site is audited.", - }, - { - type: "form", - action_id: "save_settings", - elements: [ - { - type: "number_input", - action_id: "staleMonths", - label: "Months before a published entry counts as stale", - min: 1, - max: 120, - initial_value: settings.staleMonths, - }, - { - type: "number_input", - action_id: "draftMonths", - label: "Months before a draft counts as forgotten", - min: 1, - max: 120, - initial_value: settings.draftMonths, - }, - { - type: "number_input", - action_id: "descriptionMin", - label: "Shortest acceptable SEO description", - min: 0, - max: 300, - initial_value: settings.descriptionMin, - }, - { - type: "number_input", - action_id: "descriptionMax", - label: "Longest acceptable SEO description", - min: 0, - max: 300, - initial_value: settings.descriptionMax, - }, - { - type: "toggle", - action_id: "reportMissingDescriptions", - label: "Report entries without an SEO description", - description: "Switch off if your templates always render a description of their own.", - initial_value: settings.reportMissingDescriptions, - }, - { - type: "number_input", - action_id: "pageSize", - label: "Entries per run", - min: 1, - max: 200, - initial_value: settings.pageSize, - }, - { type: "text_input", action_id: "schedule", label: "Schedule (cron)", initial_value: settings.schedule }, - ], - }, + { type: "context", text: "Every collection on the site is audited." }, + form, ], }; } diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index 0a82e78..167c413 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -4,6 +4,10 @@ * Every rule reads fields the content API already returns * (`updatedAt`, `status`, `scheduledAt`, `seo`), so the whole audit runs * without network access and without knowing the collection's schema. + * + * 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"; @@ -20,18 +24,17 @@ export const RULES = [ export type Rule = (typeof RULES)[number]; -export interface Finding { - collection: string; - entryId: string; - slug: string | null; - locale: string | null; +export type HitParams = Record; + +export interface Hit { rule: Rule; severity: Severity; - /** One sentence, shown verbatim in the report table. */ - detail: string; - entryUpdatedAt: string; + 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; @@ -51,6 +54,13 @@ export const DEFAULT_THRESHOLDS: Thresholds = { reportMissingDescriptions: true, }; +/** + * 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 @@ -65,35 +75,21 @@ function fallbackField(data: Record): string | null { return null; } -export function evaluateEntry( - entry: PluginContentItem, - collection: string, - thresholds: Thresholds, - now: Date, -): Finding[] { - const findings: Finding[] = []; - const base = { - collection, - entryId: entry.id, - slug: entry.slug, - locale: entry.locale ?? null, - entryUpdatedAt: entry.updatedAt, - }; - +export function evaluateEntry(entry: PluginContentItem, thresholds: Thresholds, now: Date): Hit[] { + const hits: Hit[] = []; const published = entry.status === "published"; const updatedAt = parseDate(entry.updatedAt); - // A schedule that came and went without publishing. Highest severity - // because someone expected this to be live — and it is a failure mode - // this stack actually produces: with autosave the publish button does - // not re-enable, so a scheduled draft can sit there indefinitely. + // 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 && !published && scheduledAt < now) { - findings.push({ - ...base, + if (scheduledAt && scheduledAt.getTime() < now.getTime() - SCHEDULE_GRACE_MS) { + hits.push({ rule: "overdue-schedule", severity: "high", - detail: `Scheduled for ${scheduledAt.toISOString().slice(0, 10)} but still ${entry.status}.`, + params: { date: day(scheduledAt), kind: published ? "update" : "publish", status: entry.status }, }); } @@ -104,48 +100,43 @@ export function evaluateEntry( if (!description) { if (thresholds.reportMissingDescriptions) { const fallback = fallbackField(entry.data); - findings.push({ - ...base, + hits.push({ rule: "missing-description", severity: fallback ? "low" : "medium", - detail: fallback - ? `No SEO description; templates often show the "${fallback}" field instead.` - : "Published without an SEO description.", + params: fallback ? { field: fallback } : {}, }); } } else if ( description.length < thresholds.descriptionMin || description.length > thresholds.descriptionMax ) { - findings.push({ - ...base, + hits.push({ rule: "description-length", severity: "low", - detail: `SEO description is ${description.length} characters; aim for ${thresholds.descriptionMin}–${thresholds.descriptionMax}.`, + params: { + length: description.length, + min: thresholds.descriptionMin, + max: thresholds.descriptionMax, + }, }); } } if (updatedAt) { if (published && updatedAt < subtractMonths(now, thresholds.staleMonths)) { - findings.push({ - ...base, - rule: "stale", - severity: "medium", - detail: `Not touched since ${updatedAt.toISOString().slice(0, 10)}.`, - }); + hits.push({ rule: "stale", severity: "medium", params: { since: day(updatedAt) } }); } if (entry.status === "draft" && updatedAt < subtractMonths(now, thresholds.draftMonths)) { - findings.push({ - ...base, - rule: "stale-draft", - severity: "low", - detail: `Draft untouched since ${updatedAt.toISOString().slice(0, 10)}.`, - }); + hits.push({ rule: "stale-draft", severity: "low", params: { since: day(updatedAt) } }); } } - return findings; + 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])); } /** @@ -153,14 +144,14 @@ export function evaluateEntry( * 28 (or 29) February rather than rolling forward into March. */ export function subtractMonths(from: Date, months: number): Date { - const day = from.getUTCDate(); + 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(day, lastDay)); + shifted.setUTCDate(Math.min(date, lastDay)); return shifted; } @@ -171,7 +162,6 @@ export function parseDate(value: string | null | undefined): Date | null { return Number.isNaN(date.getTime()) ? null : date; } -/** Stable storage id, so a re-run overwrites instead of duplicating. */ -export function findingId(collection: string, entryId: string, rule: Rule): string { - return `${collection}:${entryId}:${rule}`; +function day(date: Date): string { + return date.toISOString().slice(0, 10); } diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 64e5b46..8b3e254 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -1,7 +1,9 @@ /** - * Settings in plugin KV under `settings:*`. A standard-format plugin has - * no declarative `settingsSchema` — that is native-only — so the form is - * hand-built in `report.ts`. + * 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`. */ import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; @@ -9,8 +11,10 @@ import type { PluginContext } from "emdash/plugin"; import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; +export const SETTINGS_KEY = "settings"; + export interface Settings extends Thresholds { - /** Entries per cron tick. */ + /** Entries per cron run. `content.list` returns at most 100. */ pageSize: number; schedule: string; } @@ -21,49 +25,46 @@ export const DEFAULT_SETTINGS: Settings = { schedule: "0 4 * * *", }; -export async function readSettings(ctx: PluginContext): Promise { - const [pageSize, schedule, staleMonths, draftMonths, descriptionMin, descriptionMax, reportMissing] = - await Promise.all([ - ctx.kv.get("settings:pageSize"), - ctx.kv.get("settings:schedule"), - ctx.kv.get("settings:staleMonths"), - ctx.kv.get("settings:draftMonths"), - ctx.kv.get("settings:descriptionMin"), - ctx.kv.get("settings:descriptionMax"), - ctx.kv.get("settings:reportMissingDescriptions"), - ]); +type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax"; - const min = clampNumber(descriptionMin, 0, 300, DEFAULT_SETTINGS.descriptionMin); - const max = clampNumber(descriptionMax, 0, 300, DEFAULT_SETTINGS.descriptionMax); +const NUMBERS: Array<[NumberKey, number, number]> = [ + ["pageSize", 1, 100], + ["staleMonths", 1, 120], + ["draftMonths", 1, 120], + ["descriptionMin", 0, 300], + ["descriptionMax", 0, 300], +]; - return { - pageSize: clampNumber(pageSize, 1, 200, DEFAULT_SETTINGS.pageSize), - schedule: typeof schedule === "string" && schedule.trim() ? schedule.trim() : DEFAULT_SETTINGS.schedule, - staleMonths: clampNumber(staleMonths, 1, 120, DEFAULT_SETTINGS.staleMonths), - draftMonths: clampNumber(draftMonths, 1, 120, DEFAULT_SETTINGS.draftMonths), - descriptionMin: Math.min(min, max), - descriptionMax: Math.max(min, max), - reportMissingDescriptions: - typeof reportMissing === "boolean" ? reportMissing : DEFAULT_SETTINGS.reportMissingDescriptions, - }; +export async function readSettings(ctx: PluginContext): Promise { + return normalizeSettings(await ctx.kv.get>(SETTINGS_KEY)); } -export async function writeSettings(ctx: PluginContext, values: Record): Promise { - if (typeof values.schedule === "string" && values.schedule.trim()) - await ctx.kv.set("settings:schedule", values.schedule.trim()); - if (typeof values.reportMissingDescriptions === "boolean") - await ctx.kv.set("settings:reportMissingDescriptions", values.reportMissingDescriptions); +/** 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); + return settings; +} - const numbers: Array<[string, number, number, number]> = [ - ["pageSize", 1, 200, DEFAULT_SETTINGS.pageSize], - ["staleMonths", 1, 120, DEFAULT_SETTINGS.staleMonths], - ["draftMonths", 1, 120, DEFAULT_SETTINGS.draftMonths], - ["descriptionMin", 0, 300, DEFAULT_SETTINGS.descriptionMin], - ["descriptionMax", 0, 300, DEFAULT_SETTINGS.descriptionMax], - ]; - for (const [key, min, max, fallback] of numbers) { - if (values[key] !== undefined) { - await ctx.kv.set(`settings:${key}`, clampNumber(values[key], min, max, fallback)); - } +/** `current` with the submitted form values applied. Unknown keys are ignored. */ +export function applyForm(current: Settings, values: Record): 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; } + return normalizeSettings(merged); } diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts new file mode 100644 index 0000000..fbbc52e --- /dev/null +++ b/packages/content-freshness/src/state.ts @@ -0,0 +1,57 @@ +/** + * Everything the audit remembers between runs, under one KV key: one read + * and at most one write per run, however many collections the site has. + */ + +import type { PluginContext } from "emdash/plugin"; + +import type { Settings } from "./settings.js"; + +export const STATE_KEY = "state"; + +/** The recurring audit. `ctx.cron.schedule()` upserts on this name. */ +export const AUDIT_TASK = "audit"; + +export interface Sweep { + /** 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"; +} + +export interface State { + sweep: Sweep | null; + lastFinishedAt: string | null; + /** The expression the recurring audit was last scheduled with. */ + scheduledAs: string | null; +} + +const EMPTY: State = { sweep: null, lastFinishedAt: null, scheduledAs: 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); +} + +/** + * Register the recurring audit unless it already runs on `settings.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. Returns whether + * `state` changed and needs writing. + */ +export async function ensureScheduled(ctx: PluginContext, settings: Settings, state: State): Promise { + if (state.scheduledAs === settings.schedule || !ctx.cron) return false; + await ctx.cron.schedule(AUDIT_TASK, { schedule: settings.schedule }); + state.scheduledAs = settings.schedule; + return true; +} diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index d552ae1..e7bbed7 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -1,75 +1,131 @@ /** - * One cron tick: audit the next page of the next unfinished collection. + * The audit: a sweep over every collection, one page per cron run. * - * Cheaper than the link checker, because evaluation is local — no - * outbound request per entry. The cost per tick is one `content.list`, - * one `putMany` and one `deleteMany`, regardless of page size, which - * matters under the sandbox's 10-subrequest budget (the limit counts - * service-binding calls, so `ctx.storage` spends from it too). + * 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. A run costs two KV reads, one `content.list`, at most one + * `putMany` and one `deleteMany`, one KV write and one schedule, plus the + * collection list when a sweep starts. + * + * 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 { nextCollectionPage, resetCursors } from "@eisbachcode/emdash-plugin-shared"; +import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; -import { evaluateEntry, findingId, RULES, type Finding } from "./rules.js"; -import type { Settings } from "./settings.js"; +import { evaluateEntries, store } from "./findings.js"; +import { readSettings, type Settings } from "./settings.js"; +import { AUDIT_TASK, readState, writeState, 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; + +/** Most stale rows one cleanup run removes: storage queries return at most 100. */ +const CLEANUP_BATCH = 100; + +/** + * 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; + if (!ctx.content) return; + + const [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); + const now = new Date(); -export const CURSOR_PREFIX = "state:cursor:"; + 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, now); + if (!state.sweep) return; + } -export interface StoredFinding extends Finding { - foundAt: string; + 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`); + } } -export interface SweepResult { - collection: string; - entries: number; - found: number; - /** True when this page finished the last collection. */ - complete: boolean; +async function newSweep(ctx: PluginContext, now: Date): Promise { + const collections = (await listCollections(ctx)).map((collection) => collection.slug); + if (collections.length === 0) return null; + return { startedAt: now.toISOString(), collections, index: 0, cursor: null, phase: "audit" }; } -export async function auditNextPage( - ctx: PluginContext, - settings: Settings, - collections: string[], -): Promise { - const page = await nextCollectionPage(ctx, collections, CURSOR_PREFIX, settings.pageSize); - if (!page) return null; +function abandoned(sweep: Sweep, now: Date): boolean { + return now.getTime() - Date.parse(sweep.startedAt) > ABANDON_AFTER_MS; +} - const now = new Date(); - const writes: Array<{ id: string; data: StoredFinding }> = []; - const clears: string[] = []; - - for (const entry of page.items) { - const findings = evaluateEntry(entry, page.collection, settings, now); - const fired = new Set(findings.map((finding) => finding.rule)); - - for (const finding of findings) { - writes.push({ - id: findingId(page.collection, entry.id, finding.rule), - data: { ...finding, foundAt: now.toISOString() }, - }); - } +/** 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; - // A rule that no longer fires had its finding fixed. Clearing the - // row here is what keeps the report from accumulating history. - for (const rule of RULES) { - if (!fired.has(rule)) clears.push(findingId(page.collection, entry.id, rule)); + 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"; } - if (writes.length > 0) await ctx.storage.findings.putMany(writes); - if (clears.length > 0) await ctx.storage.findings.deleteMany(clears); + const stale = await ctx.storage.findings.query({ + where: { seenIn: { lt: sweep.startedAt } }, + limit: CLEANUP_BATCH, + }); + if (stale.items.length > 0) await ctx.storage.findings.deleteMany(stale.items.map((item) => item.id)); + if (stale.hasMore) return true; - return { - collection: page.collection, - entries: page.items.length, - found: writes.length, - complete: false, - }; + state.sweep = null; + state.lastFinishedAt = now.toISOString(); + ctx.log.info("Freshness audit complete"); + return false; } -export async function finishSweep(ctx: PluginContext, collections: string[]): Promise { - await resetCursors(ctx, collections, CURSOR_PREFIX); - await ctx.kv.set("state:lastSweepFinishedAt", new Date().toISOString()); +async function auditPage( + ctx: PluginContext, + settings: Settings, + sweep: Sweep, + collection: string, + now: Date, +): Promise { + const content = ctx.content; + if (!content) return; + let page; + try { + page = await content.list(collection, { + limit: settings.pageSize, + ...(sweep.cursor ? { cursor: sweep.cursor } : {}), + }); + } catch (error) { + // A collection deleted since the sweep started. Its rows fail the + // cleanup's `seenIn` check and go with the other leftovers. + ctx.log.warn(`Skipping collection ${collection}: ${String(error)}`); + sweep.index += 1; + sweep.cursor = null; + return; + } + + await store(ctx, evaluateEntries(collection, page.items, settings, now, sweep.startedAt)); + + if (page.cursor) { + sweep.cursor = page.cursor; + } else { + sweep.index += 1; + sweep.cursor = null; + } } diff --git a/packages/content-freshness/tests/audit.test.ts b/packages/content-freshness/tests/audit.test.ts new file mode 100644 index 0000000..39ca667 --- /dev/null +++ b/packages/content-freshness/tests/audit.test.ts @@ -0,0 +1,105 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +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("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 new file mode 100644 index 0000000..850b1e7 --- /dev/null +++ b/packages/content-freshness/tests/bridge-calls.ts @@ -0,0 +1,55 @@ +import { PluginBridge } from "@emdash-cms/plugin-test/worker"; + +/** + * The bridge calls a sandboxed invocation makes. + * + * On Cloudflare every `ctx` call crosses the bridge as one subrequest, and + * EmDash gives a sandboxed invocation ten; the eleventh aborts it. The + * local harness enforces no limit, so this wraps the class those calls + * land on, the harness's own `PluginBridge`. workerd constructs a fresh + * entrypoint instance for every incoming RPC call, so the first method + * that runs on an instance is the call; later ones on the same instance + * are the bridge using its own helpers. + */ + +const calls: string[] = []; +const seen = new WeakSet(); +const failing = new Set(); +let installed = false; + +function install(): void { + if (installed) return; + installed = true; + const proto = PluginBridge.prototype as unknown as Record; + for (const name of Object.getOwnPropertyNames(proto)) { + const original = Object.getOwnPropertyDescriptor(proto, name)?.value; + if (name === "constructor" || typeof original !== "function") continue; + proto[name] = function (this: object, ...args: unknown[]) { + if (!seen.has(this)) { + seen.add(this); + calls.push(name); + if (failing.delete(name)) return Promise.reject(new Error(`${name} failed (test)`)); + } + return (original as (...a: unknown[]) => unknown).apply(this, args); + }; + } +} + +/** Run one invocation and return the bridge calls it made, in order. */ +export async function bridgeCalls(invocation: () => Promise): Promise { + install(); + calls.length = 0; + await invocation(); + return [...calls]; +} + +/** + * Make the next call to a bridge method fail, as a lost subrequest would. + * + * The harness's RPC wrapper also prints the rejection as an uncaught + * exception. The plugin still receives it; the test run is unaffected. + */ +export function failNextCall(method: string): void { + install(); + failing.add(method); +} diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts new file mode 100644 index 0000000..b94c1d9 --- /dev/null +++ b/packages/content-freshness/tests/budget.test.ts @@ -0,0 +1,174 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AUDIT_NOW_ACTION, PAGE_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { SETTINGS_KEY } from "../src/settings.js"; +import { STATE_KEY, 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 = { + startedAt: new Date().toISOString(), + collections: ["posts"], + index: 1, + cursor: null, + phase: "cleanup", + }; + 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("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("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", PAGE_ACTION, { value: { 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 index 97e9e1a..a9c38f5 100644 --- a/packages/content-freshness/tests/cron-names.test.ts +++ b/packages/content-freshness/tests/cron-names.test.ts @@ -11,7 +11,7 @@ import plugin from "../src/plugin.js"; * handler has to answer to both. */ -/** Minimal context: `readSettings` reads plugin KV and nothing else. */ +/** Minimal context: settings and state come from KV, and the site has no collections, so no sweep starts. */ function fakeContext() { const warns: string[] = []; const reads: string[] = []; @@ -22,6 +22,8 @@ function fakeContext() { return null; }, }, + content: {}, + schema: { listCollections: async () => [] }, log: { debug() {}, info() {}, diff --git a/packages/content-freshness/tests/full-audit.test.ts b/packages/content-freshness/tests/full-audit.test.ts index c50b4e8..baf9e2a 100644 --- a/packages/content-freshness/tests/full-audit.test.ts +++ b/packages/content-freshness/tests/full-audit.test.ts @@ -1,6 +1,8 @@ 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 @@ -39,6 +41,6 @@ describe("a scheduled audit", () => { } expect(runs).toBeLessThan(20); - expect(await host.inspect.kv.get("state:lastSweepFinishedAt")).not.toBeNull(); + 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 new file mode 100644 index 0000000..334faa3 --- /dev/null +++ b/packages/content-freshness/tests/host.ts @@ -0,0 +1,47 @@ +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/permissions.test.ts b/packages/content-freshness/tests/permissions.test.ts index aac9afe..4aef9ff 100644 --- a/packages/content-freshness/tests/permissions.test.ts +++ b/packages/content-freshness/tests/permissions.test.ts @@ -82,6 +82,6 @@ describe("saving settings", () => { expect(writes.length).toBeGreaterThan(0); // The cron is re-registered so a changed expression takes effect. expect(scheduled).toContain("audit"); - expect(result.toast).toBeUndefined(); + expect(result.toast?.type).toBe("success"); }); }); diff --git a/packages/content-freshness/tests/plugin.test.ts b/packages/content-freshness/tests/plugin.test.ts index 6ab9ced..abfb1dd 100644 --- a/packages/content-freshness/tests/plugin.test.ts +++ b/packages/content-freshness/tests/plugin.test.ts @@ -9,7 +9,16 @@ import plugin from "../src/plugin.js"; */ describe("plugin surface", () => { it("registers the audit hooks", () => { - expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual(["cron", "plugin:activate"]); + 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 and status routes", () => { diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts new file mode 100644 index 0000000..16e2471 --- /dev/null +++ b/packages/content-freshness/tests/report-paging.test.ts @@ -0,0 +1,59 @@ +import type { StatsBlock, TableBlock } 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 { PAGE_ACTION } from "../src/report.js"; + +/** + * Plugin storage returns at most 100 rows per query. The report used to ask + * for 200 and sort what came back, so on a site with more than 100 findings + * an urgent one could be missing from the table 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(), + locale: "en", + rank, + hits: + rank === 0 + ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }] + : [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], + entryUpdatedAt: "2025-01-01T00:00:00.000Z", + seenIn: "2026-09-25T04:00:00.000Z", + }; +} + +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]); + const table = first.blocks.find((block): block is TableBlock => block.type === "table"); + expect(table?.rows[0]).toMatchObject({ entry: "zurgent", priority: "Urgent" }); + expect(table?.next_cursor).toBeTruthy(); + + const second = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); + const next = second.blocks.find((block): block is TableBlock => block.type === "table"); + expect(next?.rows.length).toBeGreaterThan(0); + expect(next?.rows.map((r) => r.entry)).not.toContain("zurgent"); +}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index c57c141..2746949 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -1,67 +1,79 @@ +import type { Block, FormBlock, TableBlock } from "@emdash-cms/blocks"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; +import type { EntryFindings } from "../src/findings.js"; import { buildReportPage, buildSettingsPage } from "../src/report.js"; -import type { StoredFinding } from "../src/sweep.js"; +import { DEFAULT_SETTINGS } from "../src/settings.js"; +import type { State } from "../src/state.js"; -function finding(slug: string, locale: string): StoredFinding { +const STATE: State = { sweep: null, lastFinishedAt: "2026-09-24T04:10:00.000Z", scheduledAs: "0 4 * * *" }; + +function row(slug: string, locale: string): EntryFindings { return { collection: "pages", entryId: `01${slug.toUpperCase()}`, slug, locale, - rule: "missing-description", - severity: "medium", - detail: "Published without an SEO description.", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium", params: {} }], entryUpdatedAt: "2026-09-17T00:00:00.000Z", - foundAt: "2026-09-24T00:00:00.000Z", + seenIn: "2026-09-24T04:00:00.000Z", }; } -function fakeContext(findings: StoredFinding[], kv: Record = {}) { +function fakeContext(rows: EntryFindings[]) { return { - kv: { get: async (key: string) => kv[key] ?? null }, schema: { listCollections: async () => [] }, storage: { findings: { - count: async () => findings.length, - query: async () => ({ items: findings.map((data) => ({ id: data.entryId, data })), cursor: null }), + 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: false, + }), }, }, } as unknown as PluginContext; } -function table(blocks: Array>) { - return blocks.find((block) => block.type === "table") as - | { columns: Array<{ key: string }>; rows: Array> } - | undefined; +function table(blocks: Block[]) { + return blocks.find((block): block is TableBlock => block.type === "table"); } describe("the findings table", () => { it("tells 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([finding("about", "de"), finding("about", "en")])); - const rows = table(blocks)?.rows ?? []; - expect(rows.map((row) => row.locale).sort()).toEqual(["de", "en"]); + const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE); + expect(table(blocks)?.rows.map((r) => r.locale).sort()).toEqual(["de", "en"]); expect(table(blocks)?.columns.map((column) => column.key)).toContain("locale"); }); it("leaves the language out on a single-language site", async () => { - const { blocks } = await buildReportPage(fakeContext([finding("about", "en"), finding("contact", "en")])); + const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE); expect(table(blocks)?.columns.map((column) => column.key)).not.toContain("locale"); }); + + it("names the priority and every finding of an entry in one row", 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); + const [first] = table(blocks)?.rows ?? []; + expect(first?.priority).toBe("Should fix"); + expect(first?.findings).toContain("SEO description"); + expect(first?.findings).toContain("2024-05-01"); + }); }); describe("the settings form", () => { - it("shows the saved values, not blank fields", async () => { + it("shows the saved values, not blank fields", () => { // Block Kit inputs render `initial_value`; anything else leaves the field empty. - const { blocks } = await buildSettingsPage( - fakeContext([], { "settings:staleMonths": 18, "settings:reportMissingDescriptions": false }), - ); - const form = blocks.find((block) => block.type === "form") as { - elements: Array<{ action_id: string; initial_value?: unknown }>; + const { blocks } = buildSettingsPage({ ...DEFAULT_SETTINGS, staleMonths: 18, reportMissingDescriptions: false }); + 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; }; - const value = (id: string) => form.elements.find((element) => element.action_id === id)?.initial_value; 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 index 94ea5b1..b9cc72f 100644 --- a/packages/content-freshness/tests/routing.test.ts +++ b/packages/content-freshness/tests/routing.test.ts @@ -1,7 +1,9 @@ +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 type { State } from "../src/state.js"; /** * A routable collection without a URL pattern gets `/{collection}/{slug}` @@ -12,21 +14,22 @@ import { buildReportPage, buildWidget } from "../src/report.js"; type Collection = { slug: string; label: string; routable: boolean; urlPattern: string | null }; +const STATE: State = { sweep: null, lastFinishedAt: null, scheduledAs: null }; + function fakeContext(collections: Collection[]) { return { - kv: { get: async () => null }, schema: { listCollections: async () => collections }, storage: { findings: { count: async () => 0, - query: async () => ({ items: [], cursor: null }), + query: async () => ({ items: [], hasMore: false }), }, }, } as unknown as PluginContext; } -function banners(blocks: Array>) { - return blocks.filter((block) => block.type === "banner"); +function banners(blocks: Block[]) { + return blocks.filter((block): block is BannerBlock => block.type === "banner"); } describe("the URL pattern warning", () => { @@ -37,7 +40,7 @@ describe("the URL pattern warning", () => { { slug: "authors", label: "Authors", routable: false, urlPattern: null }, ]); - const { blocks } = await buildReportPage(ctx); + const { blocks } = await buildReportPage(ctx, STATE); const [banner] = banners(blocks); expect(banner?.title).toContain("Posts"); expect(banner?.title).not.toContain("Pages"); @@ -46,13 +49,13 @@ describe("the URL pattern warning", () => { 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); + const { blocks } = await buildWidget(ctx, STATE); 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)).blocks)).toHaveLength(0); - expect(banners((await buildWidget(ctx)).blocks)).toHaveLength(0); + expect(banners((await buildReportPage(ctx, STATE)).blocks)).toHaveLength(0); + expect(banners((await buildWidget(ctx, STATE)).blocks)).toHaveLength(0); }); }); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index 0d399d7..3f426aa 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -20,8 +20,7 @@ function entry(overrides: Partial = {}): PluginContentItem { } as PluginContentItem; } -const rules = (item: PluginContentItem) => - evaluateEntry(item, "posts", DEFAULT_THRESHOLDS, NOW).map((finding) => finding.rule); +const rules = (item: PluginContentItem) => evaluateEntry(item, DEFAULT_THRESHOLDS, NOW).map((hit) => hit.rule); describe("evaluateEntry", () => { it("finds nothing wrong with a fresh, described entry", () => { @@ -41,24 +40,32 @@ describe("evaluateEntry", () => { }); it("flags a schedule that passed without publishing, at high severity", () => { - const findings = evaluateEntry( + const hits = evaluateEntry( entry({ status: "draft", scheduledAt: "2026-08-01T00:00:00.000Z" }), - "posts", DEFAULT_THRESHOLDS, NOW, ); - const overdue = findings.find((finding) => finding.rule === "overdue-schedule"); - expect(overdue?.severity).toBe("high"); - expect(overdue?.detail).toContain("2026-08-01"); + const overdue = hits.find((hit) => hit.rule === "overdue-schedule"); + expect(overdue).toMatchObject({ severity: "high", params: { date: "2026-08-01", kind: "publish" } }); }); - it("ignores a schedule in the future, and one that already published", () => { - expect(rules(entry({ status: "draft", scheduledAt: "2027-01-01T00:00:00.000Z" }))).not.toContain( - "overdue-schedule", + 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(rules(entry({ status: "published", scheduledAt: "2026-08-01T00:00:00.000Z" }))).not.toContain( + 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", () => { @@ -80,25 +87,28 @@ describe("evaluateEntry", () => { 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 [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); - expect(finding).toMatchObject({ rule: "missing-description", severity: "low" }); - expect(finding?.detail).toContain(Object.keys(data)[0]!); + 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 [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); - expect(finding?.severity).toBe("medium"); + 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 }), "posts", off, NOW)).toEqual([]); + 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 }), "posts", off, NOW).map((f) => f.rule)).toEqual([ + expect(evaluateEntry(entry({ seo: short }), off, NOW).map((hit) => hit.rule)).toEqual([ "description-length", ]); }); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 13137a1..f5661e9 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -1,38 +1,33 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SETTINGS, readSettings, writeSettings } from "../src/settings.js"; +import { applyForm, DEFAULT_SETTINGS, normalizeSettings } from "../src/settings.js"; -describe("readSettings", () => { - it("returns defaults on an empty store", async () => { - expect(await readSettings(fakeKv({}))).toEqual(DEFAULT_SETTINGS); +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", async () => { - const settings = await readSettings( - fakeKv({ "settings:descriptionMin": 200, "settings:descriptionMax": 40 }), - ); + 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]); }); -}); -describe("writeSettings", () => { - it("clamps numbers and ignores unknown keys", async () => { - const store: Record = {}; - await writeSettings(fakeKv(store), { staleMonths: 9999, nonsense: true }); - expect(store["settings:staleMonths"]).toBe(120); - expect(store).not.toHaveProperty("nonsense"); + it("caps the page size at what content.list returns", () => { + // content.list clamps its limit at 100; a larger setting would promise + // work a run never does. + expect(normalizeSettings({ pageSize: 200 }).pageSize).toBe(100); }); }); -function fakeKv(store: 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 () => [], - }, - } as unknown as import("emdash/plugin").PluginContext; -} +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 }); + }); +}); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts new file mode 100644 index 0000000..6370737 --- /dev/null +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -0,0 +1,90 @@ +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 { SETTINGS_KEY } from "../src/settings.js"; +import { 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(await host.inspect.kv.get(SETTINGS_KEY)).toBeNull(); + 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", "findings_page", { value: { cursor: "not-a-cursor" } }); + + expect(valid(response)).toBe(true); + expect(JSON.stringify(response.blocks)).toContain("no-description"); + }); +}); diff --git a/packages/content-freshness/tests/sweep.test.ts b/packages/content-freshness/tests/sweep.test.ts deleted file mode 100644 index b0e3a95..0000000 --- a/packages/content-freshness/tests/sweep.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_SETTINGS, type Settings } from "../src/settings.js"; -import { auditNextPage } from "../src/sweep.js"; - -const SETTINGS: Settings = { ...DEFAULT_SETTINGS, pageSize: 10 }; -const COLLECTIONS = ["posts"]; - -describe("auditNextPage", () => { - it("writes findings and clears the rules that no longer fire, in one call each", async () => { - const writes: Array<{ id: string }> = []; - const clears: string[] = []; - let putCalls = 0; - let deleteCalls = 0; - - const ctx = fakeCtx( - [{ items: [staleEntry()], cursor: null }], - { - putMany: async (items) => { - putCalls += 1; - writes.push(...items); - }, - deleteMany: async (ids) => { - deleteCalls += 1; - clears.push(...ids); - return ids.length; - }, - }, - ); - - const result = await auditNextPage(ctx, SETTINGS, COLLECTIONS); - expect(result).toMatchObject({ collection: "posts", entries: 1, found: 1 }); - expect(writes.map((item) => item.id)).toEqual(["posts:01STALE:stale"]); - // The other four rules are cleared so a fixed finding disappears. - expect(clears).toHaveLength(4); - // Batched: one write call and one delete call for the whole page. - expect([putCalls, deleteCalls]).toEqual([1, 1]); - }); - - it("returns null when every collection is walked", async () => { - const ctx = fakeCtx([], {}); - // The fake serves an empty last page, so the cursor lands on "done". - await auditNextPage(ctx, SETTINGS, COLLECTIONS); - expect(await auditNextPage(ctx, SETTINGS, COLLECTIONS)).toBeNull(); - }); -}); - -function staleEntry() { - return { - id: "01STALE", - type: "posts", - slug: "old-news", - status: "published", - locale: "en", - data: {}, - createdAt: "2020-01-01T00:00:00.000Z", - updatedAt: "2020-01-01T00:00:00.000Z", - publishedAt: "2020-01-01T00:00:00.000Z", - }; -} - -function fakeCtx( - pages: Array<{ items: ReturnType[]; cursor: string | null }>, - storage: { - putMany?: (items: Array<{ id: string; data: unknown }>) => Promise; - deleteMany?: (ids: string[]) => Promise; - }, -) { - const store: Record = {}; - let served = 0; - 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 () => pages[served++] ?? { items: [], cursor: null }, - get: async () => null, - }, - storage: { - findings: { - putMany: storage.putMany ?? (async () => {}), - deleteMany: storage.deleteMany ?? (async () => 0), - count: async () => 0, - query: async () => ({ items: [], cursor: null }), - }, - }, - } as unknown as import("emdash/plugin").PluginContext; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34050f8..6430dd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,13 +19,13 @@ importers: 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) + 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.0 - version: 0.2.0(f863f6b7fe5271f41d62dece9033c2f3) + version: 0.2.0(23e33028ac8753110cfb4cf3c9afb42f) 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) @@ -44,12 +44,15 @@ importers: '@eisbachcode/emdash-plugin-shared': specifier: workspace:* version: link:../shared + '@emdash-cms/blocks': + specifier: 0.39.0 + version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@emdash-cms/plugin-cli': specifier: 0.12.0 - version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + 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.1)(@emnapi/runtime@1.11.1) '@emdash-cms/plugin-test': specifier: 0.2.3 - version: 0.2.3(f64bdd591a184e04878ebed7d065ed68) + version: 0.2.3(824e54279456c15d4fb280fbef81e1d3) 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) @@ -64,7 +67,7 @@ importers: 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) + 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))(@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))(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 @@ -4662,6 +4665,28 @@ snapshots: - '@types/react' - date-fns + '@cloudflare/kumo@2.6.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)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': + dependencies: + '@base-ui/react': 1.8.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + clsx: 2.1.1 + motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + shiki: 4.4.3 + tailwind-merge: 3.7.0 + optionalDependencies: + echarts: 6.1.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260921.1)': @@ -4984,6 +5009,84 @@ snapshots: - typescript - zod + '@emdash-cms/admin@0.40.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.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)(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 + '@cloudflare/kumo': 2.6.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)(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.40.1(@types/react@19.3.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 + modern-tar: 0.7.7 + 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' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - date-fns + - echarts + - prosemirror-model + - prosemirror-state + - prosemirror-view + - typescript + - zod + '@emdash-cms/auth@0.39.0(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': dependencies: '@oslojs/crypto': 1.0.1 @@ -5033,6 +5136,22 @@ 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) @@ -5065,9 +5184,9 @@ 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)': + '@emdash-cms/blocks@0.40.1(@types/react@19.3.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.6.5) + '@cloudflare/kumo': 2.6.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)(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 @@ -5081,7 +5200,7 @@ snapshots: - date-fns - zod - '@emdash-cms/cloudflare@0.39.0(a7ee94a070d1e2ec5745e252646edfd0)': + '@emdash-cms/cloudflare@0.39.0(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 @@ -5094,7 +5213,7 @@ snapshots: ulidx: 2.4.1 wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) optionalDependencies: - '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@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 @@ -5125,7 +5244,7 @@ snapshots: - typescript - utf-8-validate - '@emdash-cms/cloudflare@0.40.1(4393bffb26115eecd490f095bbbbcb1e)': + '@emdash-cms/cloudflare@0.40.1(a7ee94a070d1e2ec5745e252646edfd0)': dependencies: '@astrojs/cloudflare': 14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1) '@cloudflare/ai-search-snippet': 0.0.42 @@ -5138,7 +5257,7 @@ snapshots: 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) + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: 8.23.0 react: 19.3.0 @@ -5184,6 +5303,45 @@ snapshots: '@wordpress/block-serialization-default-parser': 5.56.0 parse5: 7.3.0 + '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@atcute/oauth-node-client': 2.0.1(@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@clack/prompts': 1.8.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@6.0.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@oslojs/crypto': 1.0.1 + chokidar: 5.0.0 + citty: 0.2.2 + consola: 3.4.2 + jsonc-parser: 3.3.1 + modern-tar: 0.7.7 + picocolors: 1.1.1 + tsdown: 0.20.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(typescript@6.0.3) + typescript: 6.0.3 + zod: 4.5.4 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - '@vitejs/devtools' + - oxc-resolver + - publint + - synckit + - unplugin-lightningcss + - unplugin-unused + - vue-tsc + '@emdash-cms/plugin-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)': dependencies: '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) @@ -5223,11 +5381,11 @@ snapshots: - unplugin-unused - vue-tsc - '@emdash-cms/plugin-test@0.2.0(f863f6b7fe5271f41d62dece9033c2f3)': + '@emdash-cms/plugin-test@0.2.0(23e33028ac8753110cfb4cf3c9afb42f)': dependencies: '@cloudflare/vitest-plugin': 1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))) - '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) - '@emdash-cms/cloudflare': 0.39.0(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/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) + '@emdash-cms/cloudflare': 0.39.0(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.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) @@ -5285,12 +5443,12 @@ snapshots: - wrangler - zod - '@emdash-cms/plugin-test@0.2.3(f64bdd591a184e04878ebed7d065ed68)': + '@emdash-cms/plugin-test@0.2.3(824e54279456c15d4fb280fbef81e1d3)': 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/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(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@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 @@ -6107,6 +6265,14 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) @@ -7247,6 +7413,94 @@ snapshots: - 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))(@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))(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/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@emdash-cms/admin': 0.40.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.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)(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.40.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6) + '@emdash-cms/blocks': 0.40.1(@types/react@19.3.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.40.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@modelcontextprotocol/sdk': 1.30.1(supports-color@10.2.2)(zod@4.5.4) + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@portabletext/toolkit': 5.0.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@unpic/placeholder': 0.1.2 + arctic: 3.7.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + astro-portabletext: 0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)) + blurhash: 2.0.5 + citty: 0.2.2 + consola: 3.4.2 + croner: 10.0.1 + highlight.js: 11.11.2 + jose: 6.2.12 + jpeg-js: 0.4.4 + jsonc-parser: 3.3.1 + kysely: 0.29.6 + lowlight: 3.3.0 + mime: 4.1.0 + modern-tar: 0.7.7 + picocolors: 1.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + sanitize-html: 2.17.7 + sax: 1.6.1 + smol-toml: 1.9.0 + ulidx: 2.4.1 + upng-js: 2.1.0 + zod: 4.5.4 + optionalDependencies: + '@libsql/kysely-libsql': 0.4.1(kysely@0.29.6) + pg: 8.23.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + - utf-8-validate + empathic@2.1.0: {} encodeurl@2.0.0: {} @@ -8131,6 +8385,23 @@ snapshots: nlcst-to-string: 4.0.0 unist-util-visit: 5.1.0 + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(typescript@6.0.3): + dependencies: + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + ast-kit: 3.0.0 + birpc: 4.2.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.3 + obug: 2.2.1 + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 @@ -8148,7 +8419,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 @@ -8186,6 +8457,28 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + dependencies: + '@oxc-project/types': 0.112.0 + '@rolldown/pluginutils': 1.0.0-rc.3 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-x64': 1.0.0-rc.3 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.112.0 @@ -8456,6 +8749,35 @@ snapshots: trough@2.2.0: {} + tsdown@0.20.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(typescript@6.0.3): + dependencies: + ansis: 4.4.0 + cac: 6.7.14 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.2.5 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(typescript@6.0.3) + semver: 7.8.5 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.39 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + tsdown@0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3): dependencies: ansis: 4.4.0 @@ -8496,7 +8818,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 From 2124470f6213af48de912044056a42252b65b086 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 22:11:01 +0200 Subject: [PATCH 8/9] fix(content-freshness): findings from the review of the sandbox fixes - A failing `content.list` counted as "collection deleted": the sweep skipped the collection, and its cleanup then removed every finding of a collection that was never audited. Only a collection that `schema.getCollection` no longer knows is skipped now. Any other error propagates before the state is written, and the next scheduled run retries the page. - An uninstall that keeps data deletes the plugin's cron tasks but not its KV, so a stale "already scheduled" record kept the audit from ever being registered again. Activation always schedules. - Admin requests wrote the whole sweep state back after awaiting the scheduler, which could overwrite a cron run's progress. The record of the registered schedule lives in the settings key now, which only admin requests write. - A scheduling error took down every admin page, the settings page included. Page loads log it and render; the settings form reports the scheduler's own reason instead of calling every failure an invalid expression. - "Load more" after the cursor's row was removed returned an empty page, because storage continues from that row's current rank. An empty later page falls back to the first. - The status route names its counts: `entriesBySeverity` with high, medium, low and total. --- packages/content-freshness/src/plugin.ts | 49 ++++++++++++------- packages/content-freshness/src/report.ts | 34 ++++++++----- packages/content-freshness/src/schedule.ts | 31 ++++++++++++ packages/content-freshness/src/settings.ts | 24 ++++++++- packages/content-freshness/src/state.ts | 28 ++--------- packages/content-freshness/src/sweep.ts | 18 +++++-- .../content-freshness/tests/audit.test.ts | 40 +++++++++++++++ .../tests/report-paging.test.ts | 18 +++++++ .../content-freshness/tests/report.test.ts | 2 +- .../content-freshness/tests/routing.test.ts | 2 +- .../content-freshness/tests/surfaces.test.ts | 26 +++++++++- 11 files changed, 209 insertions(+), 63 deletions(-) create mode 100644 packages/content-freshness/src/schedule.ts diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index f9aee0e..f9779bd 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -19,8 +19,9 @@ import { PAGE_ACTION, SAVE_SETTINGS_ACTION, } from "./report.js"; -import { applyForm, readSettings, SETTINGS_KEY } from "./settings.js"; -import { ensureScheduled, readState, writeState } from "./state.js"; +import { ensureScheduled } from "./schedule.js"; +import { applyForm, readStoredSettings, writeStoredSettings } from "./settings.js"; +import { readState } from "./state.js"; import { AUDIT_NOW_TASK, runAudit } from "./sweep.js"; interface AdminInteraction { @@ -31,11 +32,23 @@ interface AdminInteraction { values?: Record; } -/** Settings and state, with the recurring audit registered if it was not yet. */ +/** + * 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 [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); - if (await ensureScheduled(ctx, settings, state)) await writeState(ctx, state); - return { settings, state }; + 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); } function cursorOf(value: unknown): string | undefined { @@ -64,7 +77,9 @@ const plugin = { hooks: { "plugin:activate": { handler: async (_event, ctx) => { - await loadAdmin(ctx); + const stored = await readStoredSettings(ctx); + await ensureScheduled(ctx, stored, true); + await writeStoredSettings(ctx, stored); }, }, @@ -157,7 +172,7 @@ const plugin = { return { lastSweepFinishedAt: state.lastFinishedAt, sweepInProgress: state.sweep !== null, - counts, + entriesBySeverity: counts, }; }, }, @@ -169,26 +184,26 @@ async function saveSettings( user: Parameters[0], values: Record, ) { - const [current, state] = await Promise.all([readSettings(ctx), readState(ctx)]); + const stored = await readStoredSettings(ctx); if (!hasRole(user, ROLE.ADMIN)) { return { - ...buildSettingsPage(current), + ...buildSettingsPage(stored.settings), toast: { message: "Only an administrator can change these settings.", type: "error" as const }, }; } - const next = applyForm(current, values); + const next = { settings: applyForm(stored.settings, values), scheduledAs: stored.scheduledAs }; // Schedule first: an expression the scheduler rejects is never stored. try { - await ensureScheduled(ctx, next, state); - } catch { + await ensureScheduled(ctx, next); + } catch (error) { return { - ...buildSettingsPage(current), - toast: { message: `"${next.schedule}" is not a cron expression the scheduler accepts.`, type: "error" as const }, + ...buildSettingsPage(stored.settings), + toast: { message: `Settings not saved. ${errorMessage(error)}`, type: "error" as const }, }; } - await Promise.all([ctx.kv.set(SETTINGS_KEY, next), writeState(ctx, state)]); - return { ...buildSettingsPage(next), toast: { message: "Settings saved.", type: "success" as const } }; + await writeStoredSettings(ctx, next); + return { ...buildSettingsPage(next.settings), toast: { message: "Settings saved.", type: "success" as const } }; } /** diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index efc798f..8cf8393 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -21,6 +21,7 @@ import type { PluginContext } from "emdash/plugin"; import { describeHit } from "./describe.js"; import type { EntryFindings } from "./findings.js"; +import { RANK } from "./rules.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; import type { Settings } from "./settings.js"; import type { State } from "./state.js"; @@ -35,26 +36,28 @@ const PAGE_ROWS = 50; const PRIORITY = ["Urgent", "Should fix", "Nice to fix"] as const; +/** Entries by the severity of their most urgent finding. */ export interface Counts { - /** Entries by their most urgent finding: index 0 urgent, 1 should fix, 2 nice to fix. */ - byRank: [number, number, number]; + high: number; + medium: number; + low: number; total: number; } export async function countFindings(ctx: PluginContext): Promise { - const byRank = (await Promise.all([0, 1, 2].map((rank) => ctx.storage.findings.count({ rank })))) as [ - number, - number, - number, - ]; - return { byRank, total: byRank[0] + byRank[1] + byRank[2] }; + 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(counts: Counts, withTotal: boolean): StatsBlock { return { type: "stats", items: [ - ...PRIORITY.map((label, rank) => ({ label, value: counts.byRank[rank] ?? 0 })), + { label: PRIORITY[RANK.high], value: counts.high }, + { label: PRIORITY[RANK.medium], value: counts.medium }, + { label: PRIORITY[RANK.low], value: counts.low }, ...(withTotal ? [{ label: "Entries", value: counts.total }] : []), ], }; @@ -109,16 +112,21 @@ export async function buildWidget(ctx: PluginContext, state: State): Promise 0) return { page, later: true }; } catch { - // A cursor from the client that storage cannot decode. + // Undecodable cursor. } } return { page: await ctx.storage.findings.query(query), later: false }; diff --git a/packages/content-freshness/src/schedule.ts b/packages/content-freshness/src/schedule.ts new file mode 100644 index 0000000..09fb17d --- /dev/null +++ b/packages/content-freshness/src/schedule.ts @@ -0,0 +1,31 @@ +/** + * 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 index 8b3e254..0ecaecc 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -4,6 +4,10 @@ * 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"; @@ -35,8 +39,26 @@ const NUMBERS: Array<[NumberKey, number, number]> = [ ["descriptionMax", 0, 300], ]; +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 normalizeSettings(await ctx.kv.get>(SETTINGS_KEY)); + 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. */ diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts index fbbc52e..47ae1d3 100644 --- a/packages/content-freshness/src/state.ts +++ b/packages/content-freshness/src/state.ts @@ -1,17 +1,12 @@ /** - * Everything the audit remembers between runs, under one KV key: one read - * and at most one write per run, however many collections the site has. + * 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"; -import type { Settings } from "./settings.js"; - export const STATE_KEY = "state"; -/** The recurring audit. `ctx.cron.schedule()` upserts on this name. */ -export const AUDIT_TASK = "audit"; - export interface Sweep { /** Rows written by this sweep carry this as `seenIn`; older ones are removed at the end. */ startedAt: string; @@ -26,11 +21,9 @@ export interface Sweep { export interface State { sweep: Sweep | null; lastFinishedAt: string | null; - /** The expression the recurring audit was last scheduled with. */ - scheduledAs: string | null; } -const EMPTY: State = { sweep: null, lastFinishedAt: null, scheduledAs: null }; +const EMPTY: State = { sweep: null, lastFinishedAt: null }; export async function readState(ctx: PluginContext): Promise { const stored = await ctx.kv.get>(STATE_KEY); @@ -40,18 +33,3 @@ export async function readState(ctx: PluginContext): Promise { export async function writeState(ctx: PluginContext, state: State): Promise { await ctx.kv.set(STATE_KEY, state); } - -/** - * Register the recurring audit unless it already runs on `settings.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. Returns whether - * `state` changed and needs writing. - */ -export async function ensureScheduled(ctx: PluginContext, settings: Settings, state: State): Promise { - if (state.scheduledAs === settings.schedule || !ctx.cron) return false; - await ctx.cron.schedule(AUDIT_TASK, { schedule: settings.schedule }); - state.scheduledAs = settings.schedule; - return true; -} diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index e7bbed7..10d3dd7 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -17,7 +17,8 @@ import type { PluginContext } from "emdash/plugin"; import { evaluateEntries, store } from "./findings.js"; import { readSettings, type Settings } from "./settings.js"; -import { AUDIT_TASK, readState, writeState, type State, type Sweep } from "./state.js"; +import { AUDIT_TASK } from "./schedule.js"; +import { readState, writeState, 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"; @@ -112,9 +113,13 @@ async function auditPage( ...(sweep.cursor ? { cursor: sweep.cursor } : {}), }); } catch (error) { - // A collection deleted since the sweep started. Its rows fail the - // cleanup's `seenIn` check and go with the other leftovers. - ctx.log.warn(`Skipping collection ${collection}: ${String(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; @@ -129,3 +134,8 @@ async function auditPage( 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 index 39ca667..d12ad9d 100644 --- a/packages/content-freshness/tests/audit.test.ts +++ b/packages/content-freshness/tests/audit.test.ts @@ -1,6 +1,8 @@ import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { STATE_KEY } from "../src/state.js"; +import { failNextCall } from "./bridge-calls.js"; import { finding, newHost, runSweep, undescribed } from "./host.js"; let host: PluginRuntimeTestHost | undefined; @@ -68,6 +70,44 @@ describe("a sweep", () => { }); }); +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 = { startedAt: new Date().toISOString(), collections: ["gone", "posts"], index: 0, cursor: null, phase: "audit" }; + 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("content hooks", () => { it("drop an entry's row when it is deleted", async () => { host = await newHost(); diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts index 16e2471..ab5c5f6 100644 --- a/packages/content-freshness/tests/report-paging.test.ts +++ b/packages/content-freshness/tests/report-paging.test.ts @@ -57,3 +57,21 @@ it("opens with the most urgent entries however many findings there are", async ( expect(next?.rows.length).toBeGreaterThan(0); expect(next?.rows.map((r) => r.entry)).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 table = first.blocks.find((block): block is TableBlock => block.type === "table"); + const last = table?.rows.at(-1)?.entry as string; + // Its entry was fixed or deleted before "Load more". + await host.transport.invokeHook("content:afterDelete", { id: last.toUpperCase(), collection: "posts", permanent: true }); + + const next = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); + + const rows = next.blocks.find((block): block is TableBlock => block.type === "table")?.rows ?? []; + expect(rows.length).toBeGreaterThan(0); +}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index 2746949..71d6f7b 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -7,7 +7,7 @@ import { buildReportPage, buildSettingsPage } 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", scheduledAs: "0 4 * * *" }; +const STATE: State = { sweep: null, lastFinishedAt: "2026-09-24T04:10:00.000Z" }; function row(slug: string, locale: string): EntryFindings { return { diff --git a/packages/content-freshness/tests/routing.test.ts b/packages/content-freshness/tests/routing.test.ts index b9cc72f..12e18ef 100644 --- a/packages/content-freshness/tests/routing.test.ts +++ b/packages/content-freshness/tests/routing.test.ts @@ -14,7 +14,7 @@ import type { State } from "../src/state.js"; type Collection = { slug: string; label: string; routable: boolean; urlPattern: string | null }; -const STATE: State = { sweep: null, lastFinishedAt: null, scheduledAs: null }; +const STATE: State = { sweep: null, lastFinishedAt: null }; function fakeContext(collections: Collection[]) { return { diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts index 6370737..da66ba1 100644 --- a/packages/content-freshness/tests/surfaces.test.ts +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; import { SETTINGS_KEY } from "../src/settings.js"; +import { failNextCall } from "./bridge-calls.js"; import { newHost, runSweep, undescribed } from "./host.js"; /** @@ -63,7 +64,30 @@ describe("the recurring audit", () => { const response = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "every night at four" }); expect(response.toast?.type).toBe("error"); - expect(await host.inspect.kv.get(SETTINGS_KEY)).toBeNull(); + 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 * * *" }); }); From 64a3d83e6afeb051f9c67f992858afd8b615dc87 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 22:22:54 +0200 Subject: [PATCH 9/9] docs(content-freshness): 65 tests on every EmDash release from 0.39.0 to 0.40.1 --- packages/content-freshness/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 61b5662..2adaff2 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -88,10 +88,10 @@ pnpm build | EmDash | Plugin test suite | | ------ | ----------------- | -| 0.40.1 | 38 passed | -| 0.40.0 | 38 passed | -| 0.39.1 | 38 passed | -| 0.39.0 | 38 passed | +| 0.40.1 | 65 passed | +| 0.40.0 | 65 passed | +| 0.39.1 | 65 passed | +| 0.39.0 | 65 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