diff --git a/AGENTS.md b/AGENTS.md index f2231c5..6e107c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,13 @@ pnpm build Versions come from changesets: add one with `pnpm changeset` for every change to a published package, written for someone upgrading. +A package that has not been released yet stays `"private": true`. The release +workflow publishes every public package whose version is missing from npm, +changeset or not, so a public package would go out with its first merge to +`main`. The release PR removes the flag and adds the package's first +changeset. That first publish is manual (`pnpm publish` in the package): +npm's trusted-publisher setting can only be added to a package that exists. + Never name a package script `publish`, `version` or `prepare`: npm and pnpm run scripts with those names on their own during a publish or a version bump. `emdash-plugin init` generates `"publish": "emdash-plugin publish"`, 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..1d40e50 --- /dev/null +++ b/packages/content-freshness/package.json @@ -0,0 +1,66 @@ +{ + "name": "@eisbachcode/emdash-plugin-content-freshness", + "version": "0.1.0", + "private": true, + "description": "EmDash plugin: scheduled freshness audit for your content.", + "keywords": [ + "emdash", + "emdash-plugin", + "cms", + "plugin", + "content", + "audit", + "seo" + ], + "license": "MIT", + "author": "Eisbachcode ", + "homepage": "https://github.com/eisbachcode/emdash-plugins/tree/main/packages/content-freshness#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/eisbachcode/emdash-plugins.git", + "directory": "packages/content-freshness" + }, + "bugs": "https://github.com/eisbachcode/emdash-plugins/issues", + "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" + }, + "files": [ + "dist", + "emdash-plugin.jsonc" + ], + "scripts": { + "validate": "emdash-plugin validate", + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", + "typecheck": "tsc --noEmit", + "test": "emdash-plugin validate && vitest run", + "bundle": "emdash-plugin bundle", + "registry:login": "emdash-plugin login", + "registry:publish": "emdash-plugin publish", + "release:setup": "emdash-plugin release setup", + "prepublishOnly": "pnpm run build" + }, + "engines": { + "node": ">=22.16" + }, + "peerDependencies": { + "emdash": ">=0.39.0" + }, + "devDependencies": { + "@eisbachcode/emdash-plugin-shared": "workspace:*", + "@emdash-cms/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/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(); + }); +}); 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/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); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b37865a..57cdbfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,10 +42,31 @@ importers: specifier: 4.5.4 version: 4.5.4 + 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(81a5ff34b1b2e6a02b669d3cb1fb735b) + 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 + vitest: + specifier: ^4.1.0 + version: 4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1)) + packages/shared: devDependencies: emdash: - specifier: '>=0.36.0 <1.0.0' + 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 @@ -623,6 +644,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==} @@ -639,6 +682,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==} @@ -4895,6 +4944,50 @@ snapshots: - typescript - utf-8-validate + '@emdash-cms/cloudflare@0.39.1(a7ee94a070d1e2ec5745e252646edfd0)': + dependencies: + '@astrojs/cloudflare': 14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1) + '@cloudflare/ai-search-snippet': 0.0.42 + '@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.5.4) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pg: 8.23.0 + react: 19.3.0 + transitivePeerDependencies: + - '@astrojs/react' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emdash-cms/auth-atproto' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - react-dom + - supports-color + - typescript + - utf-8-validate + '@emdash-cms/gutenberg-to-portable-text@0.39.0': dependencies: '@wordpress/block-serialization-default-parser': 5.56.0 @@ -5006,6 +5099,68 @@ snapshots: - wrangler - zod + '@emdash-cms/plugin-test@0.2.1(81a5ff34b1b2e6a02b669d3cb1fb735b)': + 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.5.4) + '@emdash-cms/cloudflare': 0.39.1(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.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 @@ -7719,7 +7874,7 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@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 @@ -8067,7 +8222,7 @@ snapshots: obug: 2.2.1 picomatch: 4.0.7 rolldown: 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@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) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17