From 3e87809e53ff35e8d723ec40f91e8931570b20ff Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 21:12:29 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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/6] 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/6] 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