From b8e6ac0c4bbd8f1d743e1b54dd4d41ee431b430b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:39:35 +0200 Subject: [PATCH 1/9] feat(content-freshness): a report that links every entry to its editor Block Kit table cells hold text only, so an editor reading "about" in the old table had to find the entry by hand. Each entry is now a section named after its title, collection and, on a multilingual site, language, with an "Open" link into the entry editor, followed by its priority and findings. Filters for collection and priority, and paging at 25 entries. A block action carries only its own value, so every control carries the whole view: a filter keeps the other one, and paging keeps both. The widget links to the report. All text comes from a message catalogue, English for now, and rows store the entry's title and author for the report and the widget. --- .../content-freshness/emdash-plugin.jsonc | 2 +- packages/content-freshness/src/describe.ts | 26 +- packages/content-freshness/src/findings.ts | 5 + packages/content-freshness/src/i18n.ts | 106 +++++++ packages/content-freshness/src/plugin.ts | 46 ++- packages/content-freshness/src/report.ts | 287 ++++++++++++------ .../content-freshness/tests/budget.test.ts | 4 +- .../tests/report-paging.test.ts | 42 +-- .../content-freshness/tests/report.test.ts | 105 +++++-- .../content-freshness/tests/routing.test.ts | 8 +- .../content-freshness/tests/surfaces.test.ts | 2 +- 11 files changed, 447 insertions(+), 186 deletions(-) create mode 100644 packages/content-freshness/src/i18n.ts diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc index 39a285c..e7a0cd3 100644 --- a/packages/content-freshness/emdash-plugin.jsonc +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -33,7 +33,7 @@ // One row per entry with at least one finding. `rank` orders the // report most urgent first; `seenIn` finds the rows a finished sweep // did not confirm, which belong to entries that no longer exist. - "findings": { "indexes": ["collection", "rank", "seenIn"] }, + "findings": { "indexes": ["collection", "rank", "seenIn", "authorId", ["collection", "rank"]] }, }, "admin": { diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts index 8fc87af..bd49442 100644 --- a/packages/content-freshness/src/describe.ts +++ b/packages/content-freshness/src/describe.ts @@ -1,25 +1,33 @@ /** - * The sentence the report shows for a hit, built from its stored params. + * The sentence shown for a hit, built from its stored params in the + * reader's language. */ +import { t, type Lang } from "./i18n.js"; import type { Hit } from "./rules.js"; -export function describeHit(hit: Hit): string { +function statusText(lang: Lang, status: string | number | undefined): string { + if (status === "draft") return t(lang, "statusDraft"); + if (status === "scheduled") return t(lang, "statusScheduled"); + return t(lang, "statusOther", { status: String(status ?? "") }); +} + +export function describeHit(lang: Lang, hit: Hit): string { const { params } = hit; switch (hit.rule) { case "overdue-schedule": return params.kind === "update" - ? `Changes scheduled for ${params.date} were never published.` - : `Scheduled for ${params.date} but still ${params.status}.`; + ? t(lang, "hitOverdueUpdate", { date: params.date ?? "" }) + : t(lang, "hitOverduePublish", { date: params.date ?? "", status: statusText(lang, params.status) }); case "missing-description": return params.field - ? `No SEO description; templates often show the "${params.field}" field instead.` - : "Published without an SEO description."; + ? t(lang, "hitMissingFallback", { field: params.field }) + : t(lang, "hitMissingDescription"); case "description-length": - return `SEO description is ${params.length} characters; aim for ${params.min}–${params.max}.`; + return t(lang, "hitDescriptionLength", params); case "stale": - return `Not touched since ${params.since}.`; + return t(lang, "hitStale", params); case "stale-draft": - return `Draft untouched since ${params.since}.`; + return t(lang, "hitStaleDraft", params); } } diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts index 9fd6318..8f39297 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -16,7 +16,10 @@ export interface EntryFindings { collection: string; entryId: string; slug: string | null; + /** The entry's `title` field, when it has one, for the report's headings. */ + title: string | null; locale: string | null; + authorId: string | null; rank: number; hits: Hit[]; entryUpdatedAt: string; @@ -55,7 +58,9 @@ export function evaluateEntries( collection, entryId: entry.id, slug: entry.slug, + title: typeof entry.data.title === "string" && entry.data.title.trim() ? entry.data.title.trim() : null, locale: entry.locale ?? null, + authorId: entry.authorId ?? null, rank: worstRank(hits), hits, entryUpdatedAt: entry.updatedAt, diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts new file mode 100644 index 0000000..b8998e4 --- /dev/null +++ b/packages/content-freshness/src/i18n.ts @@ -0,0 +1,106 @@ +/** + * The plugin's own message catalogue. + * + * Block Kit plugins localize themselves: the host passes the administrator's + * locale in `routeCtx.ui` and does not consume plugin catalogues. English is + * the source and the fallback. Manifest strings (page labels, widget and + * panel titles) stay static. Logs stay English. + */ + +type Plural = { one: string; other: string }; +type Message = string | Plural; + +const en = { + reportTitle: "Content freshness", + priorityHigh: "Urgent", + priorityMedium: "Should fix", + priorityLow: "Nice to fix", + statEntries: "Entries", + auditRunning: "Audit in progress since {date}", + auditLast: "Last audit {date}", + auditPending: "First audit pending", + nothingTitle: "Nothing needs attention", + nothingText: "No stale entries, no missing descriptions, no overdue schedules.", + noMatch: "No entry matches this filter.", + filterCollection: "Collection", + filterPriority: "Priority", + filterAll: "All", + open: "Open", + nextPage: "Next page", + firstPage: "First page", + auditNow: "Audit now", + auditContinues: "The running audit continues with the next scheduled run.", + auditStarts: "The audit starts with the next scheduled run.", + urlPatternTitle: "No URL pattern: {names}", + urlPatternDetail: + "EmDash links their entries as /{collection}/{slug} in the sitemap and the admin, which is wrong unless the site uses exactly those routes. Set a URL pattern under Content Types, or switch off routing for a collection that has no pages of its own.", + urlPatternShort: "Sitemap links for these collections may be broken.", + + widgetClear: "Everything current", + widgetLastAudit: "Last audit {date}.", + widgetPending: "Runs on the next scheduled audit.", + widgetYours: { one: "{count} of them is yours", other: "{count} of them are yours" }, + openReport: "Open report", + + panelClear: "Nothing to fix here.", + markReviewed: "Mark as reviewed", + ignore: "Ignore", + undo: "Undo", + reviewedUntil: "Marked as reviewed; checked again from {date}.", + ignored: "Ignored.", + dismissedHeader: "Set aside", + + settingsTitle: "Freshness settings", + settingsIntro: "These thresholds apply to every collection unless a collection sets its own below.", + staleMonths: "Months before a published entry counts as stale", + draftMonths: "Months before a draft counts as forgotten", + descriptionMin: "Shortest acceptable SEO description", + descriptionMax: "Longest acceptable SEO description", + reportMissing: "Report entries without an SEO description", + reportMissingHelp: "Switch off if your templates always render a description of their own.", + pageSize: "Entries per run", + schedule: "Schedule (cron, UTC)", + collectionsHeader: "Per collection", + collectionStale: "{collection}: months before stale (0 = never)", + collectionDraft: "{collection}: months before a draft is forgotten (0 = never)", + collectionSkip: "{collection}: leave out of the audit", + useDefault: "Leave empty for the default of {months}.", + save: "Save", + saved: "Settings saved.", + notSaved: "Settings not saved. {reason}", + adminOnly: "Only an administrator can change these settings.", + + hitOverduePublish: "Scheduled for {date} but still {status}.", + hitOverdueUpdate: "Changes scheduled for {date} were never published.", + hitMissingDescription: "Published without an SEO description.", + hitMissingFallback: "No SEO description; templates often show the “{field}” field instead.", + hitDescriptionLength: "SEO description is {length} characters; aim for {min}–{max}.", + hitStale: "Not touched since {since}.", + hitStaleDraft: "Draft untouched since {since}.", + statusDraft: "a draft", + statusScheduled: "scheduled", + statusOther: "{status}", +} satisfies Record; + +export type MessageKey = keyof typeof en; + +const catalogues = { en } as const; + +export type Lang = keyof typeof catalogues; +export type Params = Record; + +/** The catalogue for an admin locale. English until a catalogue exists for it. */ +export function langOf(_locale: string | undefined): Lang { + return "en"; +} + +export function t(lang: Lang, key: MessageKey, params: Params = {}): string { + const message: Message = catalogues[lang][key]; + const template = + typeof message === "string" + ? message + : new Intl.PluralRules(lang).select(Number(params.count ?? 0)) === "one" + ? message.one + : message.other; + return template.replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match)); +} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index f9779bd..b28d210 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -9,15 +9,16 @@ import { hasRole, ROLE } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; import { changedEntry, deletedEntry, forget, reevaluate } from "./hooks.js"; +import { langOf, t, type Lang } from "./i18n.js"; import { AUDIT_NOW_ACTION, buildReportPage, buildSettingsPage, buildWidget, countFindings, - FIRST_PAGE_ACTION, - PAGE_ACTION, SAVE_SETTINGS_ACTION, + VIEW_ACTION, + viewFrom, } from "./report.js"; import { ensureScheduled } from "./schedule.js"; import { applyForm, readStoredSettings, writeStoredSettings } from "./settings.js"; @@ -51,11 +52,6 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function cursorOf(value: unknown): string | undefined { - if (typeof value !== "object" || value === null) return undefined; - const cursor = (value as { cursor?: unknown }).cursor; - return typeof cursor === "string" && cursor ? cursor : undefined; -} /** Re-evaluate one entry after a state change. Never fails the editor's action. */ async function refresh(ctx: PluginContext, event: unknown): Promise { @@ -128,36 +124,29 @@ const plugin = { permission: "plugins:read", handler: async (routeCtx, ctx) => { const interaction = routeCtx.input as AdminInteraction; + const lang = langOf(routeCtx.ui?.locale); if (interaction.type === "page_load") { const { settings, state } = await loadAdmin(ctx); - if (interaction.page === "widget:summary") return buildWidget(ctx, state); - if (interaction.page === "/settings") return buildSettingsPage(settings); - return buildReportPage(ctx, state); + if (interaction.page === "widget:summary") return buildWidget(ctx, state, lang); + if (interaction.page === "/settings") return buildSettingsPage(settings, lang); + return buildReportPage(ctx, state, lang); } if (interaction.type === "form_submit" && interaction.action_id === SAVE_SETTINGS_ACTION) { - return saveSettings(ctx, routeCtx.user, interaction.values ?? {}); + return saveSettings(ctx, routeCtx.user, interaction.values ?? {}, lang); } if (interaction.type === "block_action") { - if (interaction.action_id === PAGE_ACTION) { - return buildReportPage(ctx, await readState(ctx), cursorOf(interaction.value)); - } - if (interaction.action_id === FIRST_PAGE_ACTION) { - return buildReportPage(ctx, await readState(ctx)); + if (interaction.action_id === VIEW_ACTION) { + return buildReportPage(ctx, await readState(ctx), lang, viewFrom(interaction.value)); } if (interaction.action_id === AUDIT_NOW_ACTION) { await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); const state = await readState(ctx); return { - ...(await buildReportPage(ctx, state)), - toast: { - message: state.sweep - ? "The running audit continues with the next scheduled run." - : "The audit starts with the next scheduled run.", - type: "info", - }, + ...(await buildReportPage(ctx, state, lang, viewFrom(interaction.value))), + toast: { message: t(lang, state.sweep ? "auditContinues" : "auditStarts"), type: "info" }, }; } } @@ -183,12 +172,13 @@ async function saveSettings( ctx: PluginContext, user: Parameters[0], values: Record, + lang: Lang, ) { const stored = await readStoredSettings(ctx); if (!hasRole(user, ROLE.ADMIN)) { return { - ...buildSettingsPage(stored.settings), - toast: { message: "Only an administrator can change these settings.", type: "error" as const }, + ...buildSettingsPage(stored.settings, lang), + toast: { message: t(lang, "adminOnly"), type: "error" as const }, }; } @@ -198,12 +188,12 @@ async function saveSettings( await ensureScheduled(ctx, next); } catch (error) { return { - ...buildSettingsPage(stored.settings), - toast: { message: `Settings not saved. ${errorMessage(error)}`, type: "error" as const }, + ...buildSettingsPage(stored.settings, lang), + toast: { message: t(lang, "notSaved", { reason: errorMessage(error) }), type: "error" as const }, }; } await writeStoredSettings(ctx, next); - return { ...buildSettingsPage(next.settings), toast: { message: "Settings saved.", type: "success" as const } }; + return { ...buildSettingsPage(next.settings, lang), toast: { message: t(lang, "saved"), type: "success" as const } }; } /** diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index 8cf8393..bc2a673 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -12,29 +12,70 @@ import type { BannerBlock, Block, BlockResponse, + ButtonElement, FormBlock, + SelectElement, StatsBlock, - TableBlock, } from "@emdash-cms/blocks"; -import { listCollections } from "@eisbachcode/emdash-plugin-shared"; +import { listCollections, type PluginCollectionInfo } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; import { describeHit } from "./describe.js"; import type { EntryFindings } from "./findings.js"; -import { RANK } from "./rules.js"; +import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; +import { RANK } from "./rules.js"; import type { Settings } from "./settings.js"; import type { State } from "./state.js"; -export const PAGE_ACTION = "findings_page"; -export const FIRST_PAGE_ACTION = "findings_first_page"; +export const VIEW_ACTION = "report_view"; export const AUDIT_NOW_ACTION = "audit_now"; export const SAVE_SETTINGS_ACTION = "save_settings"; -/** Rows per report page. Storage queries return at most 100. */ -const PAGE_ROWS = 50; +/** Entries per report page. Three blocks each keeps a page well inside Block Kit's limits. */ +const PAGE_ENTRIES = 25; + +const PRIORITY: Record = { + [RANK.high]: "priorityHigh", + [RANK.medium]: "priorityMedium", + [RANK.low]: "priorityLow", +}; + +/** + * What the report shows. A block action carries only its own value, so every + * control that changes the view carries the whole view: a filter keeps the + * other filter, and paging keeps both. + */ +export interface ReportView { + collection: string | null; + rank: number | null; + cursor: string | null; +} -const PRIORITY = ["Urgent", "Should fix", "Nice to fix"] as const; +export const FIRST_VIEW: ReportView = { collection: null, rank: null, cursor: null }; + +/** The view a control sent: a JSON string from a select, an object from a button. */ +export function viewFrom(value: unknown): ReportView { + let source: unknown = value; + if (typeof value === "string") { + try { + source = JSON.parse(value); + } catch { + return FIRST_VIEW; + } + } + if (typeof source !== "object" || source === null) return FIRST_VIEW; + const { collection, rank, cursor } = source as Record; + return { + collection: typeof collection === "string" && collection ? collection : null, + rank: typeof rank === "number" && Object.hasOwn(PRIORITY, rank) ? rank : null, + cursor: typeof cursor === "string" && cursor ? cursor : null, + }; +} + +function viewValue(view: ReportView): string { + return JSON.stringify({ collection: view.collection, rank: view.rank, cursor: view.cursor }); +} /** Entries by the severity of their most urgent finding. */ export interface Counts { @@ -51,45 +92,42 @@ export async function countFindings(ctx: PluginContext): Promise { return { high: high ?? 0, medium: medium ?? 0, low: low ?? 0, total: (high ?? 0) + (medium ?? 0) + (low ?? 0) }; } -function statsBlock(counts: Counts, withTotal: boolean): StatsBlock { +function statsBlock(lang: Lang, counts: Counts, withTotal: boolean): StatsBlock { return { type: "stats", items: [ - { label: PRIORITY[RANK.high], value: counts.high }, - { label: PRIORITY[RANK.medium], value: counts.medium }, - { label: PRIORITY[RANK.low], value: counts.low }, - ...(withTotal ? [{ label: "Entries", value: counts.total }] : []), + { label: t(lang, "priorityHigh"), value: counts.high }, + { label: t(lang, "priorityMedium"), value: counts.medium }, + { label: t(lang, "priorityLow"), value: counts.low }, + ...(withTotal ? [{ label: t(lang, "statEntries"), value: counts.total }] : []), ], }; } -function auditStatus(state: State): string { - if (state.sweep) return `Audit in progress since ${state.sweep.startedAt.slice(0, 10)}`; - if (state.lastFinishedAt) return `Last audit ${state.lastFinishedAt.slice(0, 10)}`; - return "First audit pending"; +function auditStatus(lang: Lang, state: State): string { + if (state.sweep) return t(lang, "auditRunning", { date: state.sweep.startedAt.slice(0, 10) }); + if (state.lastFinishedAt) return t(lang, "auditLast", { date: state.lastFinishedAt.slice(0, 10) }); + return t(lang, "auditPending"); } /** A warning about collections whose public URLs EmDash gets wrong, or nothing. */ -async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { - const missing = collectionsWithoutUrlPattern(await listCollections(ctx)); +function urlPatternBanner(lang: Lang, collections: PluginCollectionInfo[], detail: boolean): BannerBlock[] { + const missing = collectionsWithoutUrlPattern(collections); if (missing.length === 0) return []; const names = missing.map((collection) => collection.label || collection.slug).join(", "); - 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.", + title: t(lang, "urlPatternTitle", { names }), + description: t(lang, detail ? "urlPatternDetail" : "urlPatternShort"), }, ]; } -export async function buildWidget(ctx: PluginContext, state: State): Promise { - const [counts, banner] = await Promise.all([countFindings(ctx), urlPatternBanner(ctx, false)]); +export async function buildWidget(ctx: PluginContext, state: State, lang: Lang): Promise { + const [counts, collections] = await Promise.all([countFindings(ctx), listCollections(ctx)]); + const banner = urlPatternBanner(lang, collections, false); if (counts.total === 0) { if (banner.length > 0) return { blocks: banner }; @@ -97,33 +135,47 @@ export async function buildWidget(ctx: PluginContext, state: State): Promise = {}; + if (view.collection) where.collection = view.collection; + if (view.rank !== null) where.rank = view.rank; + const query = { where, orderBy: { rank: "asc" as const }, limit: PAGE_ENTRIES }; + if (view.cursor) { try { - const page = await ctx.storage.findings.query({ ...query, cursor }); + const page = await ctx.storage.findings.query({ ...query, cursor: view.cursor }); if (page.items.length > 0) return { page, later: true }; } catch { // Undecodable cursor. @@ -132,83 +184,124 @@ async function findingsPage(ctx: PluginContext, cursor: string | undefined) { return { page: await ctx.storage.findings.query(query), later: false }; } +function filters(lang: Lang, view: ReportView, collections: PluginCollectionInfo[]): ActionsBlock { + const collection: SelectElement = { + type: "select", + action_id: VIEW_ACTION, + label: t(lang, "filterCollection"), + initial_value: viewValue({ ...view, cursor: null }), + options: [ + { label: t(lang, "filterAll"), value: viewValue({ ...view, collection: null, cursor: null }) }, + ...collections.map((item) => ({ + label: item.label || item.slug, + value: viewValue({ ...view, collection: item.slug, cursor: null }), + })), + ], + }; + const priority: SelectElement = { + type: "select", + action_id: VIEW_ACTION, + label: t(lang, "filterPriority"), + initial_value: viewValue({ ...view, cursor: null }), + options: [ + { label: t(lang, "filterAll"), value: viewValue({ ...view, rank: null, cursor: null }) }, + ...[RANK.high, RANK.medium, RANK.low].map((rank) => ({ + label: t(lang, PRIORITY[rank]!), + value: viewValue({ ...view, rank, cursor: null }), + })), + ], + }; + return { type: "actions", elements: [collection, priority] }; +} + +function entryBlocks(lang: Lang, row: EntryFindings, collectionLabel: string, withLocale: boolean): Block[] { + const name = row.title ?? row.slug ?? row.entryId; + const where = [name, collectionLabel, ...(withLocale && row.locale ? [row.locale] : [])].join(" · "); + const findings = row.hits.map((hit) => describeHit(lang, hit)).join(" "); + return [ + { + type: "section", + text: where, + accessory: { + type: "link", + label: t(lang, "open"), + target: { + kind: "content", + collection: row.collection, + id: row.entryId, + ...(row.locale ? { locale: row.locale } : {}), + }, + }, + }, + { type: "context", text: `${t(lang, PRIORITY[row.rank] ?? "priorityLow")}: ${findings}` }, + { type: "divider" }, + ]; +} + export async function buildReportPage( ctx: PluginContext, state: State, - cursor?: string, + lang: Lang, + view: ReportView = FIRST_VIEW, ): Promise { - const [banner, counts, { page, later }] = await Promise.all([ - urlPatternBanner(ctx, true), + const [collections, counts, { page, later }] = await Promise.all([ + listCollections(ctx), countFindings(ctx), - findingsPage(ctx, cursor), + findingsPage(ctx, view), ]); - const auditNow: ActionsBlock = { - type: "actions", - elements: [ - { type: "button", action_id: AUDIT_NOW_ACTION, label: "Audit now" }, - ...(later ? [{ type: "button" as const, action_id: FIRST_PAGE_ACTION, label: "First page" }] : []), - ], + const banner = urlPatternBanner(lang, collections, true); + const auditNow: ButtonElement = { + type: "button", + action_id: AUDIT_NOW_ACTION, + label: t(lang, "auditNow"), + value: viewValue(view), }; if (counts.total === 0) { return { blocks: [ - { type: "header", text: "Content freshness" }, + { type: "header", text: t(lang, "reportTitle") }, ...banner, - { - type: "empty", - title: "Nothing needs attention", - description: "No stale entries, no missing descriptions, no overdue schedules.", - }, - { type: "context", text: auditStatus(state) }, - auditNow, + { type: "empty", title: t(lang, "nothingTitle"), description: t(lang, "nothingText") }, + { type: "context", text: auditStatus(lang, state) }, + { type: "actions", elements: [auditNow] }, ], }; } + const labels = new Map(collections.map((item) => [item.slug, item.label || item.slug])); const rows = page.items.map(({ data }) => data as EntryFindings); - const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; - const table: TableBlock = { - type: "table", - page_action_id: PAGE_ACTION, - columns: [ - { key: "priority", label: "Priority", format: "badge" }, - { key: "entry", label: "Entry" }, - ...(multilingual ? [{ key: "locale", label: "Language" }] : []), - { key: "collection", label: "Collection" }, - { key: "findings", label: "Findings" }, - { key: "updated", label: "Last touched" }, + const withLocale = new Set(rows.map((row) => row.locale ?? null)).size > 1; + const paging: ButtonElement[] = [ + ...(later ? [{ type: "button" as const, action_id: VIEW_ACTION, label: t(lang, "firstPage"), value: { ...view, cursor: null } }] : []), + ...(page.hasMore && page.cursor + ? [{ type: "button" as const, action_id: VIEW_ACTION, label: t(lang, "nextPage"), value: { ...view, cursor: page.cursor } }] + : []), + ]; + + return { + blocks: [ + { type: "header", text: t(lang, "reportTitle") }, + ...banner, + statsBlock(lang, counts, true), + { type: "context", text: auditStatus(lang, state) }, + filters(lang, view, collections), + ...(rows.length === 0 + ? [{ type: "context" as const, text: t(lang, "noMatch") }] + : rows.flatMap((row) => entryBlocks(lang, row, labels.get(row.collection) ?? row.collection, withLocale))), + { type: "actions", elements: [...paging, auditNow] }, ], - rows: rows.map((row) => ({ - priority: PRIORITY[row.rank] ?? "", - entry: row.slug ?? row.entryId, - locale: row.locale ?? "", - collection: row.collection, - findings: row.hits.map(describeHit).join(" "), - updated: row.entryUpdatedAt.slice(0, 10), - })), - ...(page.hasMore && page.cursor ? { next_cursor: page.cursor } : {}), }; - - const blocks: Block[] = [ - { type: "header", text: "Content freshness" }, - ...banner, - statsBlock(counts, true), - { type: "context", text: auditStatus(state) }, - table, - auditNow, - ]; - return { blocks }; } -export function buildSettingsPage(settings: Settings): BlockResponse { +export function buildSettingsPage(settings: Settings, lang: Lang): BlockResponse { const form: FormBlock = { type: "form", fields: [ { type: "number_input", action_id: "staleMonths", - label: "Months before a published entry counts as stale", + label: t(lang, "staleMonths"), min: 1, max: 120, initial_value: settings.staleMonths, @@ -216,7 +309,7 @@ export function buildSettingsPage(settings: Settings): BlockResponse { { type: "number_input", action_id: "draftMonths", - label: "Months before a draft counts as forgotten", + label: t(lang, "draftMonths"), min: 1, max: 120, initial_value: settings.draftMonths, @@ -224,7 +317,7 @@ export function buildSettingsPage(settings: Settings): BlockResponse { { type: "number_input", action_id: "descriptionMin", - label: "Shortest acceptable SEO description", + label: t(lang, "descriptionMin"), min: 0, max: 300, initial_value: settings.descriptionMin, @@ -232,7 +325,7 @@ export function buildSettingsPage(settings: Settings): BlockResponse { { type: "number_input", action_id: "descriptionMax", - label: "Longest acceptable SEO description", + label: t(lang, "descriptionMax"), min: 0, max: 300, initial_value: settings.descriptionMax, @@ -240,26 +333,26 @@ export function buildSettingsPage(settings: Settings): BlockResponse { { 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.", + label: t(lang, "reportMissing"), + description: t(lang, "reportMissingHelp"), initial_value: settings.reportMissingDescriptions, }, { type: "number_input", action_id: "pageSize", - label: "Entries per run", + label: t(lang, "pageSize"), min: 1, max: 100, initial_value: settings.pageSize, }, - { type: "text_input", action_id: "schedule", label: "Schedule (cron, UTC)", initial_value: settings.schedule }, + { type: "text_input", action_id: "schedule", label: t(lang, "schedule"), initial_value: settings.schedule }, ], - submit: { label: "Save", action_id: SAVE_SETTINGS_ACTION }, + submit: { label: t(lang, "save"), action_id: SAVE_SETTINGS_ACTION }, }; return { blocks: [ - { type: "header", text: "Freshness settings" }, - { type: "context", text: "Every collection on the site is audited." }, + { type: "header", text: t(lang, "settingsTitle") }, + { type: "context", text: t(lang, "settingsIntro") }, form, ], }; diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts index b94c1d9..5ce9b37 100644 --- a/packages/content-freshness/tests/budget.test.ts +++ b/packages/content-freshness/tests/budget.test.ts @@ -1,7 +1,7 @@ import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it } from "vitest"; -import { AUDIT_NOW_ACTION, PAGE_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION, VIEW_ACTION } from "../src/report.js"; import { SETTINGS_KEY } from "../src/settings.js"; import { STATE_KEY, type State } from "../src/state.js"; import { bridgeCalls } from "./bridge-calls.js"; @@ -163,7 +163,7 @@ describe("admin requests", () => { host = await siteWithFindings(); const runtime = host; for (const invocation of [ - () => runtime.admin.act("/report", PAGE_ACTION, { value: { cursor: "x" } }), + () => runtime.admin.act("/report", VIEW_ACTION, { value: { collection: "posts", rank: 1, cursor: "x" } }), () => runtime.admin.act("/report", AUDIT_NOW_ACTION), () => runtime.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "15 3 * * *", staleMonths: 6 }), ]) { diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts index ab5c5f6..8a42402 100644 --- a/packages/content-freshness/tests/report-paging.test.ts +++ b/packages/content-freshness/tests/report-paging.test.ts @@ -1,15 +1,15 @@ -import type { StatsBlock, TableBlock } from "@emdash-cms/blocks"; +import type { ActionsBlock, Block, ButtonElement, SectionBlock, StatsBlock } from "@emdash-cms/blocks"; import { validateBlocks } from "@emdash-cms/blocks/server"; import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, expect, it } from "vitest"; import type { EntryFindings } from "../src/findings.js"; -import { PAGE_ACTION } from "../src/report.js"; +import { VIEW_ACTION } from "../src/report.js"; /** - * Plugin storage returns at most 100 rows per query. The report used to ask - * for 200 and sort what came back, so on a site with more than 100 findings - * an urgent one could be missing from the table while the stats counted it. + * Plugin storage returns at most 100 rows per query. The report once asked + * for 200 and sorted what came back, so on a site with more than 100 + * findings an urgent one could be missing while the stats counted it. * Alone in this file because plugin storage outlives a host within a file. */ @@ -25,7 +25,9 @@ function row(entryId: string, rank: number): EntryFindings { collection: "posts", entryId, slug: entryId.toLowerCase(), + title: null, locale: "en", + authorId: null, rank, hits: rank === 0 @@ -36,6 +38,13 @@ function row(entryId: string, rank: number): EntryFindings { }; } +const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); +const nextPage = (blocks: Block[]) => + blocks + .filter((block): block is ActionsBlock => block.type === "actions") + .flatMap((block) => block.elements) + .find((element): element is ButtonElement => element.type === "button" && element.action_id === VIEW_ACTION && element.label === "Next page"); + it("opens with the most urgent entries however many findings there are", async () => { host = await createPluginRuntimeTestHost(); for (let i = 0; i < 100; i++) { @@ -48,14 +57,13 @@ it("opens with the most urgent entries however many findings there are", async ( expect(validateBlocks(first.blocks).valid).toBe(true); const stats = first.blocks.find((block): block is StatsBlock => block.type === "stats"); expect(stats?.items.map((item) => item.value)).toEqual([1, 0, 100, 101]); - const table = first.blocks.find((block): block is TableBlock => block.type === "table"); - expect(table?.rows[0]).toMatchObject({ entry: "zurgent", priority: "Urgent" }); - expect(table?.next_cursor).toBeTruthy(); + expect(sections(first.blocks)[0]?.text).toMatch(/^zurgent/); - const second = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); - const next = second.blocks.find((block): block is TableBlock => block.type === "table"); - expect(next?.rows.length).toBeGreaterThan(0); - expect(next?.rows.map((r) => r.entry)).not.toContain("zurgent"); + const next = nextPage(first.blocks); + expect(next).toBeDefined(); + const second = await host.admin.act("/report", VIEW_ACTION, { value: next?.value }); + expect(sections(second.blocks).length).toBeGreaterThan(0); + expect(sections(second.blocks).map((section) => section.text).join()).not.toContain("zurgent"); }); it("falls back to the first page when the row the cursor points at is gone", async () => { @@ -65,13 +73,11 @@ it("falls back to the first page when the row the cursor points at is gone", asy await host.fixtures.plugin.storage("findings", `posts:${id}`, row(id, 1)); } const first = await host.admin.loadPage("/report"); - const table = first.blocks.find((block): block is TableBlock => block.type === "table"); - const last = table?.rows.at(-1)?.entry as string; - // Its entry was fixed or deleted before "Load more". + const last = sections(first.blocks).at(-1)?.text.split(" · ")[0] as string; + // Its entry was fixed or deleted before "Next page". await host.transport.invokeHook("content:afterDelete", { id: last.toUpperCase(), collection: "posts", permanent: true }); - const next = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); + const next = await host.admin.act("/report", VIEW_ACTION, { value: nextPage(first.blocks)?.value }); - const rows = next.blocks.find((block): block is TableBlock => block.type === "table")?.rows ?? []; - expect(rows.length).toBeGreaterThan(0); + expect(sections(next.blocks).length).toBeGreaterThan(0); }); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index 71d6f7b..7e9a42a 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -1,74 +1,127 @@ -import type { Block, FormBlock, TableBlock } from "@emdash-cms/blocks"; +import type { ActionsBlock, Block, ButtonElement, ContextBlock, FormBlock, SectionBlock, SelectElement } from "@emdash-cms/blocks"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; import type { EntryFindings } from "../src/findings.js"; -import { buildReportPage, buildSettingsPage } from "../src/report.js"; +import { buildReportPage, buildSettingsPage, VIEW_ACTION, viewFrom, type ReportView } from "../src/report.js"; import { DEFAULT_SETTINGS } from "../src/settings.js"; import type { State } from "../src/state.js"; const STATE: State = { sweep: null, lastFinishedAt: "2026-09-24T04:10:00.000Z" }; -function row(slug: string, locale: string): EntryFindings { +function row(slug: string, locale: string, extra: Partial = {}): EntryFindings { return { collection: "pages", entryId: `01${slug.toUpperCase()}`, slug, + title: null, locale, + authorId: null, rank: 1, hits: [{ rule: "missing-description", severity: "medium", params: {} }], entryUpdatedAt: "2026-09-17T00:00:00.000Z", seenIn: "2026-09-24T04:00:00.000Z", + ...extra, }; } -function fakeContext(rows: EntryFindings[]) { +function fakeContext(rows: EntryFindings[], more = false) { return { - schema: { listCollections: async () => [] }, + schema: { + listCollections: async () => [ + { slug: "pages", label: "Pages", routable: true, urlPattern: "/{slug}" }, + { slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}" }, + ], + }, storage: { findings: { count: async (where?: { rank?: number }) => rows.filter((r) => r.rank === where?.rank).length, query: async () => ({ items: rows.map((data) => ({ id: `${data.collection}:${data.entryId}`, data })), - hasMore: false, + hasMore: more, + ...(more ? { cursor: "next-page" } : {}), }), }, }, } as unknown as PluginContext; } -function table(blocks: Block[]) { - return blocks.find((block): block is TableBlock => block.type === "table"); -} +const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); +const contexts = (blocks: Block[]) => blocks.filter((block): block is ContextBlock => block.type === "context"); +const selects = (blocks: Block[]) => + blocks + .filter((block): block is ActionsBlock => block.type === "actions") + .flatMap((block) => block.elements) + .filter((element): element is SelectElement => element.type === "select"); +const buttons = (blocks: Block[]) => + blocks + .filter((block): block is ActionsBlock => block.type === "actions") + .flatMap((block) => block.elements) + .filter((element): element is ButtonElement => element.type === "button"); + +describe("the report's entries", () => { + it("link each entry to its editor, in its language", async () => { + const { blocks } = await buildReportPage(fakeContext([row("about", "de", { title: "Über uns" })]), STATE, "en"); + const [section] = sections(blocks); + expect(section?.text).toContain("Über uns"); + expect(section?.accessory).toMatchObject({ + type: "link", + target: { kind: "content", collection: "pages", id: "01ABOUT", locale: "de" }, + }); + }); + + it("name the collection by its label and the priority before the findings", async () => { + const entry = row("about", "en"); + entry.hits.push({ rule: "stale", severity: "medium", params: { since: "2024-05-01" } }); + const { blocks } = await buildReportPage(fakeContext([entry]), STATE, "en"); + expect(sections(blocks)[0]?.text).toBe("about · Pages"); + const findings = contexts(blocks).find((block) => block.text.startsWith("Should fix:")); + expect(findings?.text).toContain("SEO description"); + expect(findings?.text).toContain("2024-05-01"); + }); -describe("the findings table", () => { - it("tells the translations of one page apart on a multilingual site", async () => { + it("tell the translations of one page apart on a multilingual site", async () => { // Without the language, "about" and "about" read as a duplicate. - const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE); - expect(table(blocks)?.rows.map((r) => r.locale).sort()).toEqual(["de", "en"]); - expect(table(blocks)?.columns.map((column) => column.key)).toContain("locale"); + const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE, "en"); + expect(sections(blocks).map((section) => section.text).sort()).toEqual(["about · Pages · de", "about · Pages · en"]); }); - it("leaves the language out on a single-language site", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE); - expect(table(blocks)?.columns.map((column) => column.key)).not.toContain("locale"); + it("leave the language out on a single-language site", async () => { + const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE, "en"); + expect(sections(blocks).map((section) => section.text)).toEqual(["about · Pages", "contact · Pages"]); }); +}); - it("names the priority and every finding of an entry in one row", async () => { - const entry = row("about", "en"); - entry.hits.push({ rule: "stale", severity: "medium", params: { since: "2024-05-01" } }); - const { blocks } = await buildReportPage(fakeContext([entry]), STATE); - const [first] = table(blocks)?.rows ?? []; - expect(first?.priority).toBe("Should fix"); - expect(first?.findings).toContain("SEO description"); - expect(first?.findings).toContain("2024-05-01"); +describe("filters and paging", () => { + const filtered: ReportView = { collection: "pages", rank: 1, cursor: null }; + + it("keep the other filter when one changes", async () => { + const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, "en", filtered); + const [collection, priority] = selects(blocks); + const posts = collection?.options.find((option) => option.label === "Posts"); + expect(viewFrom(posts?.value)).toEqual({ collection: "posts", rank: 1, cursor: null }); + const urgent = priority?.options.find((option) => option.label === "Urgent"); + expect(viewFrom(urgent?.value)).toEqual({ collection: "pages", rank: 0, cursor: null }); + // The select shows the filter in force. + expect(viewFrom(collection?.initial_value)).toEqual(filtered); + }); + + it("page within the filters", async () => { + const { blocks } = await buildReportPage(fakeContext([row("about", "en")], true), STATE, "en", filtered); + const next = buttons(blocks).find((button) => button.action_id === VIEW_ACTION); + expect(viewFrom(next?.value)).toEqual({ ...filtered, cursor: "next-page" }); + }); + + it("read a view it did not write as the first page", () => { + expect(viewFrom("not json")).toEqual({ collection: null, rank: null, cursor: null }); + expect(viewFrom({ collection: 5, rank: 9, cursor: "" })).toEqual({ collection: null, rank: null, cursor: null }); }); }); describe("the settings form", () => { it("shows the saved values, not blank fields", () => { // Block Kit inputs render `initial_value`; anything else leaves the field empty. - const { blocks } = buildSettingsPage({ ...DEFAULT_SETTINGS, staleMonths: 18, reportMissingDescriptions: false }); + const { blocks } = buildSettingsPage({ ...DEFAULT_SETTINGS, staleMonths: 18, reportMissingDescriptions: false }, "en"); const form = blocks.find((block): block is FormBlock => block.type === "form"); const value = (id: string) => { const field = form?.fields.find((f) => "action_id" in f && f.action_id === id); diff --git a/packages/content-freshness/tests/routing.test.ts b/packages/content-freshness/tests/routing.test.ts index 12e18ef..52ad84b 100644 --- a/packages/content-freshness/tests/routing.test.ts +++ b/packages/content-freshness/tests/routing.test.ts @@ -40,7 +40,7 @@ describe("the URL pattern warning", () => { { slug: "authors", label: "Authors", routable: false, urlPattern: null }, ]); - const { blocks } = await buildReportPage(ctx, STATE); + const { blocks } = await buildReportPage(ctx, STATE, "en"); const [banner] = banners(blocks); expect(banner?.title).toContain("Posts"); expect(banner?.title).not.toContain("Pages"); @@ -49,13 +49,13 @@ describe("the URL pattern warning", () => { it("reaches the dashboard even when no entry has a finding", async () => { const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: null }]); - const { blocks } = await buildWidget(ctx, STATE); + const { blocks } = await buildWidget(ctx, STATE, "en"); expect(banners(blocks)).toHaveLength(1); }); it("stays out of the way when every routable collection has a pattern", async () => { const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}" }]); - expect(banners((await buildReportPage(ctx, STATE)).blocks)).toHaveLength(0); - expect(banners((await buildWidget(ctx, STATE)).blocks)).toHaveLength(0); + expect(banners((await buildReportPage(ctx, STATE, "en")).blocks)).toHaveLength(0); + expect(banners((await buildWidget(ctx, STATE, "en")).blocks)).toHaveLength(0); }); }); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts index da66ba1..25d4abd 100644 --- a/packages/content-freshness/tests/surfaces.test.ts +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -106,7 +106,7 @@ describe("the report's paging", () => { await undescribed(host, "posts", "no-description"); await runSweep(host); - const response = await host.admin.act("/report", "findings_page", { value: { cursor: "not-a-cursor" } }); + const response = await host.admin.act("/report", "report_view", { value: { cursor: "not-a-cursor" } }); expect(valid(response)).toBe(true); expect(JSON.stringify(response.blocks)).toContain("no-description"); From 1fa6e01aafe786efc02de3aa16ffabe3bae5af33 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:41:35 +0200 Subject: [PATCH 2/9] fix(content-freshness): stay under D1's bind limit In-process, storage's `getMany` and `deleteMany` put every id into one `IN (...)` next to the plugin id and the collection name, and D1 binds at most 100 parameters per statement; the sandbox bridge does the same for `getMany`. The cleanup deleted rows in batches of 100, and "Entries per run" allowed 100, so on a D1 site running the plugin in-process a cleanup with 99 or more stale rows, or a page of 99 or more clean entries, failed. Both stop at 98 now. Splitting the calls instead would spend bridge calls the sandbox budget does not have. The test host runs plugins sandboxed, where `deleteMany` deletes row by row, which is why no test caught this. --- packages/content-freshness/README.md | 2 +- packages/content-freshness/src/findings.ts | 11 +++++++++++ packages/content-freshness/src/report.ts | 4 ++-- packages/content-freshness/src/settings.ts | 5 +++-- packages/content-freshness/src/sweep.ts | 6 ++---- packages/content-freshness/tests/settings.test.ts | 8 ++++---- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 2adaff2..a4b4fbe 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -72,7 +72,7 @@ entry. Without one, the audit is scheduled and never runs. | Draft forgotten after | 6 months | For drafts. | | SEO description | 50–160 characters | Outside this range is a low-priority finding. | | Report entries without an SEO description | on | Off for a site whose templates always render a description. The length check still runs. | -| Entries per run | 50 | At most 100, the most one content query returns. The number of calls per run does not grow with it. | +| Entries per run | 50 | At most 98: D1 binds at most 100 parameters per statement, and storage adds two of its own. The number of calls per run does not grow with it. | | Schedule | `0 4 * * *` | A cron expression, in UTC. One the scheduler rejects is not saved. | ## Develop diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts index 8f39297..55262e3 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -12,6 +12,17 @@ import type { PluginContext } from "emdash/plugin"; import { evaluateEntry, worstRank, type Hit, type Thresholds } from "./rules.js"; +/** + * Most ids one storage call may carry. + * + * In-process, `getMany` and `deleteMany` build one `IN (...)` that also binds + * the plugin id and the collection name, and D1 binds at most 100 parameters + * per statement. The sandbox bridge does the same for `getMany`. Every call + * that takes a page of ids therefore stays at 98, which is why a run audits + * at most 98 entries and a cleanup run removes at most 98 rows. + */ +export const ID_BATCH = 98; + export interface EntryFindings { collection: string; entryId: string; diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index bc2a673..56f44d9 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -21,7 +21,7 @@ import { listCollections, type PluginCollectionInfo } from "@eisbachcode/emdash- import type { PluginContext } from "emdash/plugin"; import { describeHit } from "./describe.js"; -import type { EntryFindings } from "./findings.js"; +import { ID_BATCH, type EntryFindings } from "./findings.js"; import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; import { RANK } from "./rules.js"; @@ -342,7 +342,7 @@ export function buildSettingsPage(settings: Settings, lang: Lang): BlockResponse action_id: "pageSize", label: t(lang, "pageSize"), min: 1, - max: 100, + max: ID_BATCH, initial_value: settings.pageSize, }, { type: "text_input", action_id: "schedule", label: t(lang, "schedule"), initial_value: settings.schedule }, diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 0ecaecc..465a439 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -13,12 +13,13 @@ import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import { ID_BATCH } from "./findings.js"; import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; export interface Settings extends Thresholds { - /** Entries per cron run. `content.list` returns at most 100. */ + /** Entries per cron run, at most `ID_BATCH`. */ pageSize: number; schedule: string; } @@ -32,7 +33,7 @@ export const DEFAULT_SETTINGS: Settings = { type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax"; const NUMBERS: Array<[NumberKey, number, number]> = [ - ["pageSize", 1, 100], + ["pageSize", 1, ID_BATCH], ["staleMonths", 1, 120], ["draftMonths", 1, 120], ["descriptionMin", 0, 300], diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index 10d3dd7..a006cc4 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -15,7 +15,7 @@ import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; -import { evaluateEntries, store } from "./findings.js"; +import { evaluateEntries, ID_BATCH, store } from "./findings.js"; import { readSettings, type Settings } from "./settings.js"; import { AUDIT_TASK } from "./schedule.js"; import { readState, writeState, type State, type Sweep } from "./state.js"; @@ -29,8 +29,6 @@ export const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSt /** A sweep that has not finished in this long is dropped and started over. */ const ABANDON_AFTER_MS = 7 * 24 * 60 * 60 * 1000; -/** Most stale rows one cleanup run removes: storage queries return at most 100. */ -const CLEANUP_BATCH = 100; /** * Handle a cron event. A start while a sweep is under way continues that @@ -86,7 +84,7 @@ async function step(ctx: PluginContext, settings: Settings, state: State, now: D const stale = await ctx.storage.findings.query({ where: { seenIn: { lt: sweep.startedAt } }, - limit: CLEANUP_BATCH, + limit: ID_BATCH, }); if (stale.items.length > 0) await ctx.storage.findings.deleteMany(stale.items.map((item) => item.id)); if (stale.hasMore) return true; diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index f5661e9..5955e41 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -12,10 +12,10 @@ describe("normalizeSettings", () => { expect([settings.descriptionMin, settings.descriptionMax]).toEqual([40, 200]); }); - it("caps the page size at what content.list returns", () => { - // content.list clamps its limit at 100; a larger setting would promise - // work a run never does. - expect(normalizeSettings({ pageSize: 200 }).pageSize).toBe(100); + it("caps the page size below D1's bind limit", () => { + // A page's ids go into one `IN (...)` next to two bound values of + // storage's own; D1 allows 100 per statement. + expect(normalizeSettings({ pageSize: 100 }).pageSize).toBe(98); }); }); From 81eeb96dfc26ff926ef69d180ce3e5f676f0ab5e Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:45:19 +0200 Subject: [PATCH 3/9] feat(content-freshness): a Freshness panel in the entry editor, and setting findings aside The panel lists the entry's findings and checks the entry each time it opens, so a finding fixed with a plain save, which no content hook reports, leaves the report as soon as anyone looks at the entry. Its route needs `content:edit_own`: authors see their own entries' findings, the report stays at editor and above. From the panel a finding can be set aside. "Mark as reviewed" holds a stale entry or a forgotten draft back until its threshold runs out again, so an imprint that is still correct needs no edit to quiet the report. "Ignore" holds a description finding back for good. A missed schedule cannot be set aside. "Undo" brings a finding back. Set-aside findings live in a `dismissals` collection keyed like the finding rows, so a sweep reads a page's worth in one call; the content hooks and the panel share one check of an entry. --- .../content-freshness/emdash-plugin.jsonc | 4 + packages/content-freshness/src/check.ts | 67 ++++++++++++++ packages/content-freshness/src/describe.ts | 8 +- packages/content-freshness/src/dismissals.ts | 76 ++++++++++++++++ packages/content-freshness/src/findings.ts | 32 ++++--- packages/content-freshness/src/hooks.ts | 29 +------ packages/content-freshness/src/ids.ts | 19 ++++ packages/content-freshness/src/panel.ts | 65 ++++++++++++++ packages/content-freshness/src/plugin.ts | 27 +++++- packages/content-freshness/src/report.ts | 3 +- packages/content-freshness/src/settings.ts | 2 +- packages/content-freshness/src/sweep.ts | 11 ++- .../content-freshness/tests/budget.test.ts | 17 ++++ .../tests/dismissals.test.ts | 38 ++++++++ .../content-freshness/tests/panel.test.ts | 87 +++++++++++++++++++ .../content-freshness/tests/plugin.test.ts | 4 +- 16 files changed, 440 insertions(+), 49 deletions(-) create mode 100644 packages/content-freshness/src/check.ts create mode 100644 packages/content-freshness/src/dismissals.ts create mode 100644 packages/content-freshness/src/ids.ts create mode 100644 packages/content-freshness/src/panel.ts create mode 100644 packages/content-freshness/tests/dismissals.test.ts create mode 100644 packages/content-freshness/tests/panel.test.ts diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc index e7a0cd3..f4e0841 100644 --- a/packages/content-freshness/emdash-plugin.jsonc +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -34,6 +34,9 @@ // report most urgent first; `seenIn` finds the rows a finished sweep // did not confirm, which belong to entries that no longer exist. "findings": { "indexes": ["collection", "rank", "seenIn", "authorId", ["collection", "rank"]] }, + // Findings an editor set aside, one document per entry, keyed like its + // finding row. Read by id only. + "dismissals": { "indexes": [] }, }, "admin": { @@ -42,5 +45,6 @@ { "path": "/settings", "label": "Freshness settings", "icon": "settings" }, ], "widgets": [{ "id": "summary", "title": "Needs attention", "size": "third" }], + "editorPanels": [{ "id": "freshness", "title": "Freshness", "route": "panel" }], }, } diff --git a/packages/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts new file mode 100644 index 0000000..6852439 --- /dev/null +++ b/packages/content-freshness/src/check.ts @@ -0,0 +1,67 @@ +/** + * Checking one entry now, outside the sweep: after a content state change, + * and whenever its editor panel opens. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { + dismissalFor, + partition, + readDismissals, + writeDismissals, + type Dismissal, + type EntryDismissals, +} from "./dismissals.js"; +import { evaluateEntries, forget, store, type EntryRef } from "./findings.js"; +import { findingId } from "./ids.js"; +import { evaluateEntry, type Hit, type Rule } from "./rules.js"; +import { readSettings } from "./settings.js"; + +export interface PanelAction { + kind: "set-aside" | "undo"; + rule: Rule; + by: string | null; +} + +export interface PanelView { + active: Hit[]; + setAside: Array<{ hit: Hit; dismissal: Dismissal }>; +} + +/** + * Apply `action`, check the entry, store its row and return what the panel + * shows. Four `ctx` calls, five with an action. + */ +export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: PanelAction): Promise { + if (!ctx.content) return { active: [], setAside: [] }; + const [settings, entry, stored] = await Promise.all([ + readSettings(ctx), + ctx.content.get(ref.collection, ref.id), + readDismissals(ctx, ref.collection, ref.id), + ]); + if (!entry) { + await forget(ctx, ref); + return { active: [], setAside: [] }; + } + + const now = new Date(); + let dismissals: EntryDismissals = stored ?? { collection: ref.collection, entryId: ref.id, rules: {} }; + if (action) { + const rules = { ...dismissals.rules }; + if (action.kind === "undo") { + delete rules[action.rule]; + } else { + const dismissal = dismissalFor(action.rule, settings, now, action.by); + if (dismissal) rules[action.rule] = dismissal; + } + dismissals = { ...dismissals, rules }; + await writeDismissals(ctx, dismissals); + } + + const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); + await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString(), keyed)); + const { active, setAside } = partition(evaluateEntry(entry, settings, now), dismissals, now); + return { active, setAside: setAside.map((hit) => ({ hit, dismissal: dismissals.rules[hit.rule]! })) }; +} + diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts index bd49442..72fd572 100644 --- a/packages/content-freshness/src/describe.ts +++ b/packages/content-freshness/src/describe.ts @@ -4,7 +4,7 @@ */ import { t, type Lang } from "./i18n.js"; -import type { Hit } from "./rules.js"; +import type { Hit, Severity } from "./rules.js"; function statusText(lang: Lang, status: string | number | undefined): string { if (status === "draft") return t(lang, "statusDraft"); @@ -31,3 +31,9 @@ export function describeHit(lang: Lang, hit: Hit): string { return t(lang, "hitStaleDraft", params); } } + +const SEVERITY_KEY = { high: "priorityHigh", medium: "priorityMedium", low: "priorityLow" } as const; + +export function severityText(lang: Lang, severity: Severity): string { + return t(lang, SEVERITY_KEY[severity]); +} diff --git a/packages/content-freshness/src/dismissals.ts b/packages/content-freshness/src/dismissals.ts new file mode 100644 index 0000000..51bdb22 --- /dev/null +++ b/packages/content-freshness/src/dismissals.ts @@ -0,0 +1,76 @@ +/** + * Findings an editor has set aside: one document per entry, under the same + * key as its finding row, so a page of entries needs one `getMany`. + * + * "Mark as reviewed" holds a time-based finding back until the threshold has + * run out again: an imprint that is still correct should not be edited just + * to make the report quiet. "Ignore" holds a description finding back for + * good. A missed schedule cannot be set aside. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { findingId } from "./ids.js"; +import { subtractMonths, type Hit, type Rule, type Thresholds } from "./rules.js"; + +export type DismissKind = "review" | "ignore"; + +export const DISMISSIBLE: Partial> = { + stale: "review", + "stale-draft": "review", + "missing-description": "ignore", + "description-length": "ignore", +}; + +export interface Dismissal { + /** When the finding may come back; `null` for never. */ + until: string | null; + by: string | null; + at: string; +} + +export interface EntryDismissals { + collection: string; + entryId: string; + rules: Partial>; +} + +export function isSetAside(dismissals: EntryDismissals | undefined, rule: Rule, now: Date): boolean { + const dismissal = dismissals?.rules[rule]; + if (!dismissal) return false; + return dismissal.until === null || Date.parse(dismissal.until) > now.getTime(); +} + +/** `hits` split into those that stand and those set aside. */ +export function partition(hits: Hit[], dismissals: EntryDismissals | undefined, now: Date) { + const active: Hit[] = []; + const setAside: Hit[] = []; + for (const hit of hits) (isSetAside(dismissals, hit.rule, now) ? setAside : active).push(hit); + return { active, setAside }; +} + +/** A dismissal for `rule`, or null when the rule cannot be set aside. */ +export function dismissalFor(rule: Rule, thresholds: Thresholds, now: Date, by: string | null): Dismissal | null { + const kind = DISMISSIBLE[rule]; + if (!kind) return null; + if (kind === "ignore") return { until: null, by, at: now.toISOString() }; + const months = rule === "stale" ? thresholds.staleMonths : thresholds.draftMonths; + return { until: subtractMonths(now, -months).toISOString(), by, at: now.toISOString() }; +} + +export async function readDismissals( + ctx: PluginContext, + collection: string, + entryId: string, +): Promise { + return ((await ctx.storage.dismissals.get(findingId(collection, entryId))) ?? undefined) as + | EntryDismissals + | undefined; +} + +/** Write `dismissals`, or remove the document once nothing is set aside. */ +export async function writeDismissals(ctx: PluginContext, dismissals: EntryDismissals): Promise { + const id = findingId(dismissals.collection, dismissals.entryId); + if (Object.keys(dismissals.rules).length === 0) await ctx.storage.dismissals.delete(id); + else await ctx.storage.dismissals.put(id, dismissals); +} diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts index 55262e3..81e6d22 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -10,18 +10,10 @@ import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import { partition, type EntryDismissals } from "./dismissals.js"; +import { findingId } from "./ids.js"; import { evaluateEntry, worstRank, type Hit, type Thresholds } from "./rules.js"; -/** - * Most ids one storage call may carry. - * - * In-process, `getMany` and `deleteMany` build one `IN (...)` that also binds - * the plugin id and the collection name, and D1 binds at most 100 parameters - * per statement. The sandbox bridge does the same for `getMany`. Every call - * that takes a page of ids therefore stays at 98, which is why a run audits - * at most 98 entries and a cleanup run removes at most 98 rows. - */ -export const ID_BATCH = 98; export interface EntryFindings { collection: string; @@ -38,9 +30,6 @@ export interface EntryFindings { seenIn: string; } -export function findingId(collection: string, entryId: string): string { - return `${collection}:${entryId}`; -} export interface Evaluated { writes: Array<{ id: string; data: EntryFindings }>; @@ -48,17 +37,22 @@ export interface Evaluated { clears: string[]; } +/** + * Rows for `entries`, leaving out findings an editor has set aside. + * `dismissals` is keyed like the rows. + */ export function evaluateEntries( collection: string, entries: PluginContentItem[], thresholds: Thresholds, now: Date, seenIn: string, + dismissals: Map = new Map(), ): Evaluated { const result: Evaluated = { writes: [], clears: [] }; for (const entry of entries) { const id = findingId(collection, entry.id); - const hits = evaluateEntry(entry, thresholds, now); + const hits = partition(evaluateEntry(entry, thresholds, now), dismissals.get(id), now).active; if (hits.length === 0) { result.clears.push(id); continue; @@ -87,3 +81,13 @@ export async function store(ctx: PluginContext, evaluated: Evaluated): Promise 0) await ctx.storage.findings.putMany(evaluated.writes); if (evaluated.clears.length > 0) await ctx.storage.findings.deleteMany(evaluated.clears); } + +export interface EntryRef { + collection: string; + id: string; +} + +/** Remove an entry's row: it was trashed or deleted. */ +export async function forget(ctx: PluginContext, ref: EntryRef): Promise { + await ctx.storage.findings.delete(findingId(ref.collection, ref.id)); +} diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts index 64eba5b..2220580 100644 --- a/packages/content-freshness/src/hooks.ts +++ b/packages/content-freshness/src/hooks.ts @@ -5,18 +5,11 @@ * trashing or deleting it removes its row. `content:afterSave` is left out * on purpose: it also fires for every autosave, which for a sandboxed * install would start an isolate every few seconds while someone types. - * A save without a state change is picked up by the next sweep. + * A save without a state change is picked up when the entry's Freshness + * panel opens, or by the next sweep. */ -import type { PluginContext } from "emdash/plugin"; - -import { evaluateEntries, findingId, store } from "./findings.js"; -import { readSettings } from "./settings.js"; - -interface EntryRef { - collection: string; - id: string; -} +import type { EntryRef } from "./findings.js"; /** The entry a state-change event is about: `{ content, collection }`, with the id on `content`. */ export function changedEntry(event: unknown): EntryRef | null { @@ -33,19 +26,3 @@ export function deletedEntry(event: unknown): EntryRef | null { const { id, collection } = event as { id?: unknown; collection?: unknown }; return typeof id === "string" && typeof collection === "string" ? { collection, id } : null; } - -/** Evaluate one entry now and store the result. Three `ctx` calls. */ -export async function reevaluate(ctx: PluginContext, ref: EntryRef): Promise { - if (!ctx.content) return; - const [settings, entry] = await Promise.all([readSettings(ctx), ctx.content.get(ref.collection, ref.id)]); - if (!entry) { - await forget(ctx, ref); - return; - } - const now = new Date(); - await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString())); -} - -export async function forget(ctx: PluginContext, ref: EntryRef): Promise { - await ctx.storage.findings.delete(findingId(ref.collection, ref.id)); -} diff --git a/packages/content-freshness/src/ids.ts b/packages/content-freshness/src/ids.ts new file mode 100644 index 0000000..17f80f8 --- /dev/null +++ b/packages/content-freshness/src/ids.ts @@ -0,0 +1,19 @@ +/** + * Storage keys and the size of one storage call. + */ + +/** + * Most ids one storage call may carry. + * + * In-process, `getMany` and `deleteMany` build one `IN (...)` that also binds + * the plugin id and the collection name, and D1 binds at most 100 parameters + * per statement. The sandbox bridge does the same for `getMany`. Every call + * that takes a page of ids therefore stays at 98, which is why a run audits + * at most 98 entries and a cleanup run removes at most 98 rows. + */ +export const ID_BATCH = 98; + +/** The key of an entry's finding row and of its dismissals document. */ +export function findingId(collection: string, entryId: string): string { + return `${collection}:${entryId}`; +} diff --git a/packages/content-freshness/src/panel.ts b/packages/content-freshness/src/panel.ts new file mode 100644 index 0000000..507a5e6 --- /dev/null +++ b/packages/content-freshness/src/panel.ts @@ -0,0 +1,65 @@ +/** + * The entry editor's Freshness panel. + * + * It checks the entry each time it opens, so a finding fixed with a plain + * save, which no content hook reports, leaves the report as soon as someone + * looks at the entry again. It is also where findings are set aside. + */ + +import type { Block, BlockResponse } from "@emdash-cms/blocks"; + +import type { PanelView } from "./check.js"; +import { describeHit, severityText } from "./describe.js"; +import { DISMISSIBLE } from "./dismissals.js"; +import { t, type Lang } from "./i18n.js"; +import { RULES, type Rule } from "./rules.js"; + +export const PANEL_ID = "freshness"; +export const SET_ASIDE_ACTION = "set_aside"; +export const UNDO_ACTION = "undo"; + +/** The rule a panel button carries, if it names one. */ +export function ruleOf(value: unknown): Rule | null { + if (typeof value !== "object" || value === null) return null; + const rule = (value as { rule?: unknown }).rule; + return RULES.find((known) => known === rule) ?? null; +} + +export function renderPanel(lang: Lang, view: PanelView): BlockResponse { + if (view.active.length === 0 && view.setAside.length === 0) { + return { blocks: [{ type: "context", text: t(lang, "panelClear") }] }; + } + + const blocks: Block[] = view.active.map((hit) => { + const kind = DISMISSIBLE[hit.rule]; + return { + type: "section", + text: `${severityText(lang, hit.severity)}: ${describeHit(lang, hit)}`, + ...(kind + ? { + accessory: { + type: "button" as const, + action_id: SET_ASIDE_ACTION, + label: t(lang, kind === "review" ? "markReviewed" : "ignore"), + value: { rule: hit.rule }, + }, + } + : {}), + }; + }); + + if (view.setAside.length > 0) { + blocks.push({ type: "context", text: t(lang, "dismissedHeader") }); + for (const { hit, dismissal } of view.setAside) { + const until = dismissal.until + ? t(lang, "reviewedUntil", { date: dismissal.until.slice(0, 10) }) + : t(lang, "ignored"); + blocks.push({ + type: "section", + text: `${describeHit(lang, hit)} ${until}`, + accessory: { type: "button", action_id: UNDO_ACTION, label: t(lang, "undo"), value: { rule: hit.rule } }, + }); + } + } + return { blocks }; +} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index b28d210..1d3ba65 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -8,7 +8,9 @@ import { hasRole, ROLE } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; -import { changedEntry, deletedEntry, forget, reevaluate } from "./hooks.js"; +import { checkEntry, type PanelAction } from "./check.js"; +import { forget } from "./findings.js"; +import { changedEntry, deletedEntry } from "./hooks.js"; import { langOf, t, type Lang } from "./i18n.js"; import { AUDIT_NOW_ACTION, @@ -20,6 +22,7 @@ import { VIEW_ACTION, viewFrom, } from "./report.js"; +import { renderPanel, ruleOf, SET_ASIDE_ACTION, UNDO_ACTION } from "./panel.js"; import { ensureScheduled } from "./schedule.js"; import { applyForm, readStoredSettings, writeStoredSettings } from "./settings.js"; import { readState } from "./state.js"; @@ -58,7 +61,7 @@ async function refresh(ctx: PluginContext, event: unknown): Promise { const ref = changedEntry(event); if (!ref) return; try { - await reevaluate(ctx, ref); + await checkEntry(ctx, ref); } catch (error) { ctx.log.warn(`Could not re-check ${ref.collection}/${ref.id}: ${String(error)}`); } @@ -155,6 +158,26 @@ const plugin = { }, }, + // The entry editor's Freshness panel. Its own route because it needs a + // lower permission than the report: `content:edit_own` is author and + // above, and the host also checks that the user may edit this entry, + // so an author sets aside findings on their own entries only. + panel: { + permission: "content:edit_own", + handler: async (routeCtx, ctx) => { + const ui = routeCtx.ui; + if (ui?.surface !== "content-editor-panel") return { blocks: [] }; + const input = routeCtx.input as { type?: string; action_id?: string; value?: unknown }; + const rule = ruleOf(input.value); + const kind = + input.action_id === SET_ASIDE_ACTION ? "set-aside" : input.action_id === UNDO_ACTION ? "undo" : null; + const action: PanelAction | undefined = + input.type === "block_action" && rule && kind ? { kind, rule, by: routeCtx.user?.id ?? null } : undefined; + const view = await checkEntry(ctx, { collection: ui.entry.collection, id: ui.entry.id }, action); + return renderPanel(langOf(ui.locale), view); + }, + }, + status: { handler: async (_routeCtx, ctx) => { const [state, counts] = await Promise.all([readState(ctx), countFindings(ctx)]); diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index 56f44d9..a252f54 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -21,7 +21,8 @@ import { listCollections, type PluginCollectionInfo } from "@eisbachcode/emdash- import type { PluginContext } from "emdash/plugin"; import { describeHit } from "./describe.js"; -import { ID_BATCH, type EntryFindings } from "./findings.js"; +import type { EntryFindings } from "./findings.js"; +import { ID_BATCH } from "./ids.js"; import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; import { RANK } from "./rules.js"; diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 465a439..8553b6f 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -13,7 +13,7 @@ import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; -import { ID_BATCH } from "./findings.js"; +import { ID_BATCH } from "./ids.js"; import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index a006cc4..b543e84 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -15,7 +15,9 @@ import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; -import { evaluateEntries, ID_BATCH, store } from "./findings.js"; +import type { EntryDismissals } from "./dismissals.js"; +import { evaluateEntries, store } from "./findings.js"; +import { findingId, ID_BATCH } from "./ids.js"; import { readSettings, type Settings } from "./settings.js"; import { AUDIT_TASK } from "./schedule.js"; import { readState, writeState, type State, type Sweep } from "./state.js"; @@ -123,7 +125,12 @@ async function auditPage( return; } - await store(ctx, evaluateEntries(collection, page.items, settings, now, sweep.startedAt)); + const ids = page.items.map((entry) => findingId(collection, entry.id)); + const dismissals = ids.length > 0 ? await ctx.storage.dismissals.getMany(ids) : new Map(); + await store( + ctx, + evaluateEntries(collection, page.items, settings, now, sweep.startedAt, dismissals as Map), + ); if (page.cursor) { sweep.cursor = page.cursor; diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts index 5ce9b37..04e1af0 100644 --- a/packages/content-freshness/tests/budget.test.ts +++ b/packages/content-freshness/tests/budget.test.ts @@ -132,6 +132,23 @@ describe("content hooks", () => { }); }); +describe("the Freshness panel", () => { + it("opening it, and setting a finding aside", async () => { + host = await newHost(); + const runtime = host; + const entry = await undescribed(runtime, "posts", "panel-budget"); + + const load = await bridgeCalls(() => runtime.admin.loadEditorPanel("freshness", "posts", entry.id)); + expect(load.length).toBeLessThanOrEqual(LIMIT); + + const act = await bridgeCalls(() => + runtime.admin.actEditorPanel("freshness", "posts", entry.id, "set_aside", { value: { rule: "missing-description" } }), + ); + expect(act).toContain("storagePut"); + expect(act.length).toBeLessThanOrEqual(LIMIT); + }); +}); + describe("admin requests", () => { async function siteWithFindings() { const runtime = await newHost(); diff --git a/packages/content-freshness/tests/dismissals.test.ts b/packages/content-freshness/tests/dismissals.test.ts new file mode 100644 index 0000000..923443a --- /dev/null +++ b/packages/content-freshness/tests/dismissals.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { dismissalFor, partition, type EntryDismissals } from "../src/dismissals.js"; +import { DEFAULT_THRESHOLDS, type Hit } from "../src/rules.js"; + +const NOW = new Date("2026-09-25T12:00:00.000Z"); +const stale: Hit = { rule: "stale", severity: "medium", params: { since: "2025-01-01" } }; +const missing: Hit = { rule: "missing-description", severity: "medium", params: {} }; +const overdue: Hit = { rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }; + +function dismissed(rules: EntryDismissals["rules"]): EntryDismissals { + return { collection: "pages", entryId: "01A", rules }; +} + +describe("setting a finding aside", () => { + it("holds a reviewed stale entry back for as long as the stale threshold", () => { + const dismissal = dismissalFor("stale", { ...DEFAULT_THRESHOLDS, staleMonths: 24 }, NOW, "u1"); + expect(dismissal?.until?.slice(0, 10)).toBe("2028-09-25"); + }); + + it("ignores a description finding for good, and never a missed schedule", () => { + expect(dismissalFor("missing-description", DEFAULT_THRESHOLDS, NOW, null)?.until).toBeNull(); + expect(dismissalFor("overdue-schedule", DEFAULT_THRESHOLDS, NOW, null)).toBeNull(); + }); + + it("lets a review run out", () => { + const aside = dismissed({ stale: { until: "2026-09-01T00:00:00.000Z", by: null, at: "2025-09-01T00:00:00.000Z" } }); + expect(partition([stale], aside, NOW).active).toEqual([stale]); + }); + + it("keeps what is set aside out of the active findings", () => { + const aside = dismissed({ + stale: { until: "2027-01-01T00:00:00.000Z", by: null, at: NOW.toISOString() }, + "missing-description": { until: null, by: null, at: NOW.toISOString() }, + }); + expect(partition([stale, missing, overdue], aside, NOW)).toEqual({ active: [overdue], setAside: [stale, missing] }); + }); +}); diff --git a/packages/content-freshness/tests/panel.test.ts b/packages/content-freshness/tests/panel.test.ts new file mode 100644 index 0000000..9f0c725 --- /dev/null +++ b/packages/content-freshness/tests/panel.test.ts @@ -0,0 +1,87 @@ +import type { Block, ButtonElement, SectionBlock } from "@emdash-cms/blocks"; +import { validateBlocks } from "@emdash-cms/blocks/server"; +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { PANEL_ID, renderPanel, SET_ASIDE_ACTION, UNDO_ACTION } from "../src/panel.js"; +import { finding, newHost, runSweep, undescribed } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); +const button = (section: SectionBlock | undefined) => section?.accessory as ButtonElement | undefined; + +describe("the Freshness panel", () => { + it("shows the entry's findings and records them", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "panel-shows"); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + expect(validateBlocks(response.blocks).valid).toBe(true); + const [section] = sections(response.blocks); + expect(section?.text).toContain("SEO description"); + expect(button(section)).toMatchObject({ action_id: SET_ASIDE_ACTION, label: "Ignore" }); + // Opening the panel is a check: the report has the finding before any sweep. + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); + + it("sets a finding aside so that neither the report nor a sweep brings it back, and undoes it", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "panel-ignores"); + await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + const ignored = await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { + value: { rule: "missing-description" }, + }); + expect(validateBlocks(ignored.blocks).valid).toBe(true); + expect(button(sections(ignored.blocks)[0])).toMatchObject({ action_id: UNDO_ACTION }); + expect(await finding(host, "posts", entry.id)).toBeNull(); + + await runSweep(host); + expect(await finding(host, "posts", entry.id)).toBeNull(); + + await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, UNDO_ACTION, { value: { rule: "missing-description" } }); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); + + it("opens for an author on their own entry", async () => { + // The report needs plugins:read (editor). The panel is where an author + // sees and sets aside the findings on their own entries. + host = await newHost(); + const author = await host.fixtures.user({ email: "author@example.test", role: "author" }); + const entry = await host.fixtures.content("posts", { + slug: "authors-own", + data: {}, + status: "published", + authorId: author.id, + }); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id, { user: author }); + + expect(sections(response.blocks)[0]?.text).toContain("SEO description"); + }); +}); + +describe("the panel's controls", () => { + it("offer no way to set aside a missed schedule", () => { + const { blocks } = renderPanel("en", { + active: [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }], + setAside: [], + }); + expect(sections(blocks)[0]?.accessory).toBeUndefined(); + }); + + it("offer a review, not an ignore, for a stale entry", () => { + const { blocks } = renderPanel("en", { + active: [{ rule: "stale", severity: "medium", params: { since: "2025-01-01" } }], + setAside: [], + }); + expect(button(sections(blocks)[0])?.label).toBe("Mark as reviewed"); + }); +}); diff --git a/packages/content-freshness/tests/plugin.test.ts b/packages/content-freshness/tests/plugin.test.ts index abfb1dd..223347e 100644 --- a/packages/content-freshness/tests/plugin.test.ts +++ b/packages/content-freshness/tests/plugin.test.ts @@ -21,7 +21,7 @@ describe("plugin surface", () => { ]); }); - it("registers the admin and status routes", () => { - expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "status"]); + it("registers the admin, panel and status routes", () => { + expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "panel", "status"]); }); }); From 7599a0a9c0639d5527c5b2d16fe139048d0b2246 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:50:21 +0200 Subject: [PATCH 4/9] feat(content-freshness): thresholds per collection, and collections left out News goes stale sooner than a legal page, and testimonials never do. Each collection can set its own months for stale entries and forgotten drafts at the end of the settings form; an empty field keeps the site's value and 0 switches the rule off. A collection can also be left out of the audit: its rows go with the next sweep's cleanup, and the Freshness panel of its entries says it is left out. A review's "checked again from" date follows the collection's own threshold. A sweep now also runs when no collection is left to audit, so its cleanup still removes the rows of collections that were left out; before, a site whose only collection was left out kept its findings forever. --- packages/content-freshness/src/check.ts | 15 ++-- packages/content-freshness/src/i18n.ts | 9 ++- packages/content-freshness/src/panel.ts | 1 + packages/content-freshness/src/plugin.ts | 15 ++-- packages/content-freshness/src/report.ts | 41 ++++++++++- packages/content-freshness/src/rules.ts | 6 +- packages/content-freshness/src/settings.ts | 70 +++++++++++++++++++ packages/content-freshness/src/sweep.ts | 25 +++++-- .../tests/cron-names.test.ts | 4 +- .../content-freshness/tests/rules.test.ts | 7 ++ .../content-freshness/tests/settings.test.ts | 31 +++++++- .../content-freshness/tests/surfaces.test.ts | 31 +++++++- 12 files changed, 224 insertions(+), 31 deletions(-) diff --git a/packages/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts index 6852439..51bdbc9 100644 --- a/packages/content-freshness/src/check.ts +++ b/packages/content-freshness/src/check.ts @@ -16,7 +16,7 @@ import { import { evaluateEntries, forget, store, type EntryRef } from "./findings.js"; import { findingId } from "./ids.js"; import { evaluateEntry, type Hit, type Rule } from "./rules.js"; -import { readSettings } from "./settings.js"; +import { readSettings, thresholdsFor } from "./settings.js"; export interface PanelAction { kind: "set-aside" | "undo"; @@ -27,6 +27,8 @@ export interface PanelAction { export interface PanelView { active: Hit[]; setAside: Array<{ hit: Hit; dismissal: Dismissal }>; + /** The entry's collection is left out of the audit. */ + skipped?: boolean; } /** @@ -40,9 +42,10 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan ctx.content.get(ref.collection, ref.id), readDismissals(ctx, ref.collection, ref.id), ]); - if (!entry) { + const thresholds = thresholdsFor(settings, ref.collection); + if (!entry || thresholds.skip) { await forget(ctx, ref); - return { active: [], setAside: [] }; + return { active: [], setAside: [], skipped: thresholds.skip }; } const now = new Date(); @@ -52,7 +55,7 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan if (action.kind === "undo") { delete rules[action.rule]; } else { - const dismissal = dismissalFor(action.rule, settings, now, action.by); + const dismissal = dismissalFor(action.rule, thresholds, now, action.by); if (dismissal) rules[action.rule] = dismissal; } dismissals = { ...dismissals, rules }; @@ -60,8 +63,8 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan } const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); - await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString(), keyed)); - const { active, setAside } = partition(evaluateEntry(entry, settings, now), dismissals, now); + await store(ctx, evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed)); + const { active, setAside } = partition(evaluateEntry(entry, thresholds, now), dismissals, now); return { active, setAside: setAside.map((hit) => ({ hit, dismissal: dismissals.rules[hit.rule]! })) }; } diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts index b8998e4..8ef5621 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -43,6 +43,7 @@ const en = { openReport: "Open report", panelClear: "Nothing to fix here.", + panelSkipped: "This collection is left out of the freshness audit.", markReviewed: "Mark as reviewed", ignore: "Ignore", undo: "Undo", @@ -51,7 +52,7 @@ const en = { dismissedHeader: "Set aside", settingsTitle: "Freshness settings", - settingsIntro: "These thresholds apply to every collection unless a collection sets its own below.", + settingsIntro: "These thresholds apply to every collection unless a collection sets its own at the end of the form.", staleMonths: "Months before a published entry counts as stale", draftMonths: "Months before a draft counts as forgotten", descriptionMin: "Shortest acceptable SEO description", @@ -60,11 +61,9 @@ const en = { reportMissingHelp: "Switch off if your templates always render a description of their own.", pageSize: "Entries per run", schedule: "Schedule (cron, UTC)", - collectionsHeader: "Per collection", - collectionStale: "{collection}: months before stale (0 = never)", - collectionDraft: "{collection}: months before a draft is forgotten (0 = never)", + collectionStale: "{collection}: months before an entry is stale (empty: {months}, 0: never)", + collectionDraft: "{collection}: months before a draft is forgotten (empty: {months}, 0: never)", collectionSkip: "{collection}: leave out of the audit", - useDefault: "Leave empty for the default of {months}.", save: "Save", saved: "Settings saved.", notSaved: "Settings not saved. {reason}", diff --git a/packages/content-freshness/src/panel.ts b/packages/content-freshness/src/panel.ts index 507a5e6..69e5a20 100644 --- a/packages/content-freshness/src/panel.ts +++ b/packages/content-freshness/src/panel.ts @@ -26,6 +26,7 @@ export function ruleOf(value: unknown): Rule | null { } export function renderPanel(lang: Lang, view: PanelView): BlockResponse { + if (view.skipped) return { blocks: [{ type: "context", text: t(lang, "panelSkipped") }] }; if (view.active.length === 0 && view.setAside.length === 0) { return { blocks: [{ type: "context", text: t(lang, "panelClear") }] }; } diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index 1d3ba65..b0bf555 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -5,7 +5,7 @@ * `plugins: []`) and sandboxed mode. Logic lives in the sibling modules. */ -import { hasRole, ROLE } from "@eisbachcode/emdash-plugin-shared"; +import { hasRole, listCollections, ROLE } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; import { checkEntry, type PanelAction } from "./check.js"; @@ -132,7 +132,7 @@ const plugin = { if (interaction.type === "page_load") { const { settings, state } = await loadAdmin(ctx); if (interaction.page === "widget:summary") return buildWidget(ctx, state, lang); - if (interaction.page === "/settings") return buildSettingsPage(settings, lang); + if (interaction.page === "/settings") return buildSettingsPage(settings, lang, await listCollections(ctx)); return buildReportPage(ctx, state, lang); } @@ -197,10 +197,10 @@ async function saveSettings( values: Record, lang: Lang, ) { - const stored = await readStoredSettings(ctx); + const [stored, collections] = await Promise.all([readStoredSettings(ctx), listCollections(ctx)]); if (!hasRole(user, ROLE.ADMIN)) { return { - ...buildSettingsPage(stored.settings, lang), + ...buildSettingsPage(stored.settings, lang, collections), toast: { message: t(lang, "adminOnly"), type: "error" as const }, }; } @@ -211,12 +211,15 @@ async function saveSettings( await ensureScheduled(ctx, next); } catch (error) { return { - ...buildSettingsPage(stored.settings, lang), + ...buildSettingsPage(stored.settings, lang, collections), toast: { message: t(lang, "notSaved", { reason: errorMessage(error) }), type: "error" as const }, }; } await writeStoredSettings(ctx, next); - return { ...buildSettingsPage(next.settings, lang), toast: { message: t(lang, "saved"), type: "success" as const } }; + return { + ...buildSettingsPage(next.settings, lang, collections), + toast: { message: t(lang, "saved"), type: "success" as const }, + }; } /** diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index a252f54..36b4398 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -14,6 +14,7 @@ import type { BlockResponse, ButtonElement, FormBlock, + FormField, SelectElement, StatsBlock, } from "@emdash-cms/blocks"; @@ -26,7 +27,7 @@ import { ID_BATCH } from "./ids.js"; import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; import { RANK } from "./rules.js"; -import type { Settings } from "./settings.js"; +import { collectionField, type Settings } from "./settings.js"; import type { State } from "./state.js"; export const VIEW_ACTION = "report_view"; @@ -295,7 +296,42 @@ export async function buildReportPage( }; } -export function buildSettingsPage(settings: Settings, lang: Lang): BlockResponse { +function collectionFields(settings: Settings, lang: Lang, collections: PluginCollectionInfo[]): FormField[] { + return collections.flatMap((item): FormField[] => { + const own = settings.collections[item.slug] ?? {}; + const collection = item.label || item.slug; + return [ + { + type: "number_input", + action_id: collectionField(item.slug, "staleMonths"), + label: t(lang, "collectionStale", { collection, months: settings.staleMonths }), + min: 0, + max: 120, + ...(own.staleMonths !== undefined ? { initial_value: own.staleMonths } : {}), + }, + { + type: "number_input", + action_id: collectionField(item.slug, "draftMonths"), + label: t(lang, "collectionDraft", { collection, months: settings.draftMonths }), + min: 0, + max: 120, + ...(own.draftMonths !== undefined ? { initial_value: own.draftMonths } : {}), + }, + { + type: "toggle", + action_id: collectionField(item.slug, "skip"), + label: t(lang, "collectionSkip", { collection }), + initial_value: own.skip === true, + }, + ]; + }); +} + +export function buildSettingsPage( + settings: Settings, + lang: Lang, + collections: PluginCollectionInfo[] = [], +): BlockResponse { const form: FormBlock = { type: "form", fields: [ @@ -347,6 +383,7 @@ export function buildSettingsPage(settings: Settings, lang: Lang): BlockResponse initial_value: settings.pageSize, }, { type: "text_input", action_id: "schedule", label: t(lang, "schedule"), initial_value: settings.schedule }, + ...collectionFields(settings, lang, collections), ], submit: { label: t(lang, "save"), action_id: SAVE_SETTINGS_ACTION }, }; diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index 167c413..097e0fe 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -122,11 +122,13 @@ export function evaluateEntry(entry: PluginContentItem, thresholds: Thresholds, } } + // A threshold of 0 switches the rule off: a collection whose entries are + // not expected to change, such as testimonials. if (updatedAt) { - if (published && updatedAt < subtractMonths(now, thresholds.staleMonths)) { + if (published && thresholds.staleMonths > 0 && updatedAt < subtractMonths(now, thresholds.staleMonths)) { hits.push({ rule: "stale", severity: "medium", params: { since: day(updatedAt) } }); } - if (entry.status === "draft" && updatedAt < subtractMonths(now, thresholds.draftMonths)) { + if (entry.status === "draft" && thresholds.draftMonths > 0 && updatedAt < subtractMonths(now, thresholds.draftMonths)) { hits.push({ rule: "stale-draft", severity: "low", params: { since: day(updatedAt) } }); } } diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 8553b6f..3037d97 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -18,18 +18,54 @@ import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; +/** + * What one collection sets for itself. A missing threshold means the site's + * default; 0 switches the rule off for the collection. + */ +export interface CollectionOverride { + staleMonths?: number; + draftMonths?: number; + /** Leave the collection out of the audit altogether. */ + skip?: boolean; +} + export interface Settings extends Thresholds { /** Entries per cron run, at most `ID_BATCH`. */ pageSize: number; schedule: string; + collections: Record; } export const DEFAULT_SETTINGS: Settings = { ...DEFAULT_THRESHOLDS, pageSize: 50, schedule: "0 4 * * *", + collections: {}, }; +/** A collection slug as EmDash allows it. Anything else in stored settings is dropped. */ +const SLUG = /^[a-z][a-z0-9_]*$/; + +/** A form field for one collection's setting: `collection::`. */ +const COLLECTION_FIELD = /^collection:([a-z][a-z0-9_]*):(staleMonths|draftMonths|skip)$/; + +export function collectionField(slug: string, setting: keyof CollectionOverride): string { + return `collection:${slug}:${setting}`; +} + +/** The thresholds that apply to `collection`, and whether it is audited at all. */ +export function thresholdsFor(settings: Settings, collection: string): Thresholds & { skip: boolean } { + const own = settings.collections[collection] ?? {}; + return { + staleMonths: own.staleMonths ?? settings.staleMonths, + draftMonths: own.draftMonths ?? settings.draftMonths, + descriptionMin: settings.descriptionMin, + descriptionMax: settings.descriptionMax, + reportMissingDescriptions: settings.reportMissingDescriptions, + skip: own.skip === true, + }; +} + type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax"; const NUMBERS: Array<[NumberKey, number, number]> = [ @@ -76,9 +112,30 @@ export function normalizeSettings(stored: Record | null | undef const [min, max] = [settings.descriptionMin, settings.descriptionMax]; settings.descriptionMin = Math.min(min, max); settings.descriptionMax = Math.max(min, max); + settings.collections = normalizeOverrides(source.collections); return settings; } +function normalizeOverrides(source: unknown): Record { + if (typeof source !== "object" || source === null) return {}; + const overrides: Record = {}; + for (const [slug, raw] of Object.entries(source as Record)) { + if (!SLUG.test(slug) || typeof raw !== "object" || raw === null) continue; + const { staleMonths, draftMonths, skip } = raw as Record; + const own: CollectionOverride = {}; + if (isSet(staleMonths)) own.staleMonths = clampNumber(staleMonths, 0, 120, 0); + if (isSet(draftMonths)) own.draftMonths = clampNumber(draftMonths, 0, 120, 0); + if (skip === true) own.skip = true; + if (Object.keys(own).length > 0) overrides[slug] = own; + } + return overrides; +} + +/** A form's empty number field arrives as `undefined`, `null` or `""`: no override. */ +function isSet(value: unknown): boolean { + return value !== undefined && value !== null && !(typeof value === "string" && !value.trim()); +} + /** `current` with the submitted form values applied. Unknown keys are ignored. */ export function applyForm(current: Settings, values: Record): Settings { const merged: Record = { ...current }; @@ -89,5 +146,18 @@ export function applyForm(current: Settings, values: Record): S if (typeof values.reportMissingDescriptions === "boolean") { merged.reportMissingDescriptions = values.reportMissingDescriptions; } + + const collections: Record> = {}; + for (const [slug, own] of Object.entries(current.collections)) collections[slug] = { ...own }; + for (const [key, value] of Object.entries(values)) { + const match = COLLECTION_FIELD.exec(key); + if (!match) continue; + const [, slug, setting] = match as unknown as [string, string, keyof CollectionOverride]; + const own = (collections[slug] ??= {}); + if (setting === "skip") own.skip = value === true; + else if (isSet(value)) own[setting] = value; + else delete own[setting]; + } + merged.collections = collections; return normalizeSettings(merged); } diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index b543e84..5d2d6f8 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -18,7 +18,7 @@ import type { PluginContext } from "emdash/plugin"; import type { EntryDismissals } from "./dismissals.js"; import { evaluateEntries, store } from "./findings.js"; import { findingId, ID_BATCH } from "./ids.js"; -import { readSettings, type Settings } from "./settings.js"; +import { readSettings, thresholdsFor, type Settings } from "./settings.js"; import { AUDIT_TASK } from "./schedule.js"; import { readState, writeState, type State, type Sweep } from "./state.js"; @@ -49,8 +49,7 @@ export async function runAudit(ctx: PluginContext, event: ChainEvent): Promise { - const collections = (await listCollections(ctx)).map((collection) => collection.slug); - if (collections.length === 0) return null; +/** + * A sweep over every collection not left out. With none left it still runs + * its cleanup, which removes the rows of collections that were left out. + */ +async function newSweep(ctx: PluginContext, settings: Settings, now: Date): Promise { + const collections = (await listCollections(ctx)) + .map((collection) => collection.slug) + .filter((slug) => !thresholdsFor(settings, slug).skip); return { startedAt: now.toISOString(), collections, index: 0, cursor: null, phase: "audit" }; } @@ -106,6 +110,13 @@ async function auditPage( ): Promise { const content = ctx.content; if (!content) return; + const thresholds = thresholdsFor(settings, collection); + // Left out since the sweep started: its rows go with the cleanup. + if (thresholds.skip) { + sweep.index += 1; + sweep.cursor = null; + return; + } let page; try { page = await content.list(collection, { @@ -129,7 +140,7 @@ async function auditPage( const dismissals = ids.length > 0 ? await ctx.storage.dismissals.getMany(ids) : new Map(); await store( ctx, - evaluateEntries(collection, page.items, settings, now, sweep.startedAt, dismissals as Map), + evaluateEntries(collection, page.items, thresholds, now, sweep.startedAt, dismissals as Map), ); if (page.cursor) { diff --git a/packages/content-freshness/tests/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts index a9c38f5..dd59e33 100644 --- a/packages/content-freshness/tests/cron-names.test.ts +++ b/packages/content-freshness/tests/cron-names.test.ts @@ -11,7 +11,7 @@ import plugin from "../src/plugin.js"; * handler has to answer to both. */ -/** Minimal context: settings and state come from KV, and the site has no collections, so no sweep starts. */ +/** Minimal context: settings and state come from KV, and a site without collections sweeps straight to its cleanup. */ function fakeContext() { const warns: string[] = []; const reads: string[] = []; @@ -21,9 +21,11 @@ function fakeContext() { reads.push(key); return null; }, + async set() {}, }, content: {}, schema: { listCollections: async () => [] }, + storage: { findings: { query: async () => ({ items: [], hasMore: false }) } }, log: { debug() {}, info() {}, diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index 3f426aa..e61041e 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -33,6 +33,13 @@ describe("evaluateEntry", () => { expect(rules(entry({ updatedAt: "2024-01-01T00:00:00.000Z" }))).toContain("stale"); }); + it("does not call anything stale in a collection whose threshold is 0", () => { + const never = { ...DEFAULT_THRESHOLDS, staleMonths: 0, draftMonths: 0 }; + const old = "2020-01-01T00:00:00.000Z"; + expect(evaluateEntry(entry({ updatedAt: old }), never, NOW)).toEqual([]); + expect(evaluateEntry(entry({ status: "draft", updatedAt: old }), never, NOW)).toEqual([]); + }); + it("flags a draft left for longer than the draft threshold", () => { const found = rules(entry({ status: "draft", updatedAt: "2025-01-01T00:00:00.000Z" })); expect(found).toContain("stale-draft"); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 5955e41..22b1145 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { applyForm, DEFAULT_SETTINGS, normalizeSettings } from "../src/settings.js"; +import { applyForm, DEFAULT_SETTINGS, normalizeSettings, thresholdsFor } from "../src/settings.js"; describe("normalizeSettings", () => { it("returns defaults for nothing stored", () => { @@ -31,3 +31,32 @@ describe("applyForm", () => { expect(applyForm(current, { staleMonths: 18 })).toMatchObject({ staleMonths: 18, draftMonths: 3 }); }); }); + +describe("settings per collection", () => { + it("override the site's thresholds, and 0 switches a rule off", () => { + const next = applyForm(DEFAULT_SETTINGS, { + "collection:pages:staleMonths": 24, + "collection:testimonials:staleMonths": 0, + "collection:testimonials:skip": false, + }); + expect(thresholdsFor(next, "pages").staleMonths).toBe(24); + expect(thresholdsFor(next, "testimonials").staleMonths).toBe(0); + expect(thresholdsFor(next, "posts").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); + }); + + it("fall back to the site's value when the field is emptied", () => { + const current = applyForm(DEFAULT_SETTINGS, { "collection:pages:staleMonths": 24 }); + const next = applyForm(current, { "collection:pages:staleMonths": "" }); + expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); + expect(next.collections).toEqual({}); + }); + + it("leave a collection out of the audit", () => { + const next = applyForm(DEFAULT_SETTINGS, { "collection:legal:skip": true }); + expect(thresholdsFor(next, "legal").skip).toBe(true); + }); + + it("drop what is not a collection slug", () => { + expect(normalizeSettings({ collections: { "Bad Slug": { skip: true }, pages: "nonsense" } }).collections).toEqual({}); + }); +}); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts index 25d4abd..58d4563 100644 --- a/packages/content-freshness/tests/surfaces.test.ts +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -3,9 +3,10 @@ import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it } from "vitest"; import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { PANEL_ID } from "../src/panel.js"; import { SETTINGS_KEY } from "../src/settings.js"; import { failNextCall } from "./bridge-calls.js"; -import { newHost, runSweep, undescribed } from "./host.js"; +import { finding, newHost, runSweep, undescribed } from "./host.js"; /** * The host rejects a sandboxed response whose blocks do not validate with a @@ -112,3 +113,31 @@ describe("the report's paging", () => { expect(JSON.stringify(response.blocks)).toContain("no-description"); }); }); + +describe("a collection left out of the audit", () => { + it("loses its findings with the next sweep, and its entries' panel says why", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "left-out"); + await runSweep(host); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + + const saved = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:skip": true }); + expect(saved.toast?.type).toBe("success"); + await runSweep(host); + + expect(await finding(host, "posts", entry.id)).toBeNull(); + const panel = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + expect(JSON.stringify(panel.blocks)).toContain("left out of the freshness audit"); + }); + + it("shows the collection's own settings in the form", async () => { + host = await newHost(); + await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:staleMonths": 0 }); + + const page = await host.admin.loadPage("/settings"); + + expect(valid(page)).toBe(true); + const form = page.blocks.find((block) => block.type === "form") as { fields: Array<{ action_id: string; initial_value?: unknown }> }; + expect(form.fields.find((field) => field.action_id === "collection:posts:staleMonths")?.initial_value).toBe(0); + }); +}); From a55fc724d7611726d36bc487450e3e929f9d366f Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:51:06 +0200 Subject: [PATCH 5/9] feat(content-freshness): the widget counts the entries that are yours Next to the counts by priority, the dashboard card says how many of the entries that need attention the viewer wrote, read from the row's author against the signed-in user; no users:read needed. --- packages/content-freshness/src/plugin.ts | 2 +- packages/content-freshness/src/report.ts | 15 +++++++++++++-- packages/content-freshness/tests/surfaces.test.ts | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index b0bf555..6d01fb8 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -131,7 +131,7 @@ const plugin = { if (interaction.type === "page_load") { const { settings, state } = await loadAdmin(ctx); - if (interaction.page === "widget:summary") return buildWidget(ctx, state, lang); + if (interaction.page === "widget:summary") return buildWidget(ctx, state, lang, routeCtx.user?.id); if (interaction.page === "/settings") return buildSettingsPage(settings, lang, await listCollections(ctx)); return buildReportPage(ctx, state, lang); } diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index 36b4398..eb02f53 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -127,8 +127,18 @@ function urlPatternBanner(lang: Lang, collections: PluginCollectionInfo[], detai ]; } -export async function buildWidget(ctx: PluginContext, state: State, lang: Lang): Promise { - const [counts, collections] = await Promise.all([countFindings(ctx), listCollections(ctx)]); +/** The dashboard card. With `userId`, it also says how many of the entries are that user's. */ +export async function buildWidget( + ctx: PluginContext, + state: State, + lang: Lang, + userId?: string, +): Promise { + const [counts, collections, yours] = await Promise.all([ + countFindings(ctx), + listCollections(ctx), + userId ? ctx.storage.findings.count({ authorId: userId }) : Promise.resolve(0), + ]); const banner = urlPatternBanner(lang, collections, false); if (counts.total === 0) { @@ -150,6 +160,7 @@ export async function buildWidget(ctx: PluginContext, state: State, lang: Lang): blocks: [ ...banner, statsBlock(lang, counts, false), + ...(yours > 0 ? [{ type: "context" as const, text: t(lang, "widgetYours", { count: yours }) }] : []), { type: "context", text: auditStatus(lang, state) }, { type: "actions", diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts index 58d4563..04831b2 100644 --- a/packages/content-freshness/tests/surfaces.test.ts +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -141,3 +141,18 @@ describe("a collection left out of the audit", () => { expect(form.fields.find((field) => field.action_id === "collection:posts:staleMonths")?.initial_value).toBe(0); }); }); + +describe("the dashboard widget", () => { + it("tells an editor how many of the entries that need attention are theirs", async () => { + host = await newHost(); + const editor = await host.fixtures.user({ email: "editor@example.test", role: "editor" }); + await host.fixtures.content("posts", { slug: "editors", data: {}, status: "published", authorId: editor.id }); + await undescribed(host, "posts", "someone-elses"); + await runSweep(host); + + const widget = await host.admin.loadWidget("summary", { user: editor }); + + expect(valid(widget)).toBe(true); + expect(JSON.stringify(widget.blocks)).toContain("1 of them is yours"); + }); +}); From 5d29c1e2a6b6476147f12bb679d6b12bb416a84c Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:55:31 +0200 Subject: [PATCH 6/9] feat(content-freshness): German admin Every Block Kit string reads German when the administrator's locale is German, using EmDash's own German terms (Kollektion, Eintrag, Entwurf, URL-Muster). The catalogue is typed against the English keys, so a missing translation fails the typecheck. In-process installs get the locale on the editor panel today and on pages and widgets once EmDash passes it there too. --- packages/content-freshness/src/i18n.ts | 83 +++++++++++++++++-- packages/content-freshness/tests/i18n.test.ts | 36 ++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 packages/content-freshness/tests/i18n.test.ts diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts index 8ef5621..7ee1e06 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -3,7 +3,8 @@ * * Block Kit plugins localize themselves: the host passes the administrator's * locale in `routeCtx.ui` and does not consume plugin catalogues. English is - * the source and the fallback. Manifest strings (page labels, widget and + * the source and the fallback; `de` is typed against its keys, so a missing + * German entry fails the typecheck. Manifest strings (page labels, widget and * panel titles) stay static. Logs stay English. */ @@ -83,14 +84,86 @@ const en = { export type MessageKey = keyof typeof en; -const catalogues = { en } as const; +const de: Record = { + reportTitle: "Aktualität der Inhalte", + priorityHigh: "Dringend", + priorityMedium: "Bald erledigen", + priorityLow: "Bei Gelegenheit", + statEntries: "Einträge", + auditRunning: "Prüfung läuft seit {date}", + auditLast: "Letzte Prüfung {date}", + auditPending: "Erste Prüfung steht aus", + nothingTitle: "Alles aktuell", + nothingText: "Keine veralteten Einträge, keine fehlenden Beschreibungen, keine verpassten Termine.", + noMatch: "Kein Eintrag passt zu diesem Filter.", + filterCollection: "Kollektion", + filterPriority: "Priorität", + filterAll: "Alle", + open: "Öffnen", + nextPage: "Nächste Seite", + firstPage: "Erste Seite", + auditNow: "Jetzt prüfen", + auditContinues: "Die laufende Prüfung geht mit dem nächsten geplanten Lauf weiter.", + auditStarts: "Die Prüfung startet mit dem nächsten geplanten Lauf.", + urlPatternTitle: "Kein URL-Muster: {names}", + urlPatternDetail: + "EmDash verlinkt ihre Einträge in der Sitemap und im Admin als /{collection}/{slug}. Das stimmt nur, wenn die Website genau diese Routen verwendet. Lege unter Inhaltstypen ein URL-Muster fest oder schalte das Routing für eine Kollektion ohne eigene Seiten ab.", + urlPatternShort: "Sitemap-Links dieser Kollektionen sind möglicherweise falsch.", + + widgetClear: "Alles aktuell", + widgetLastAudit: "Letzte Prüfung {date}.", + widgetPending: "Läuft mit der nächsten geplanten Prüfung.", + widgetYours: { one: "{count} davon ist deiner", other: "{count} davon sind deine" }, + openReport: "Bericht öffnen", + + panelClear: "Hier gibt es nichts zu tun.", + panelSkipped: "Diese Kollektion ist von der Prüfung ausgenommen.", + markReviewed: "Als geprüft markieren", + ignore: "Ignorieren", + undo: "Rückgängig", + reviewedUntil: "Als geprüft markiert; wird ab {date} wieder geprüft.", + ignored: "Ignoriert.", + dismissedHeader: "Zurückgestellt", + + settingsTitle: "Einstellungen zur Aktualität", + settingsIntro: + "Diese Schwellen gelten für alle Kollektionen, außer eine Kollektion legt am Ende des Formulars eigene fest.", + staleMonths: "Monate, bis ein veröffentlichter Eintrag als veraltet gilt", + draftMonths: "Monate, bis ein Entwurf als vergessen gilt", + descriptionMin: "Kürzeste akzeptable SEO-Beschreibung", + descriptionMax: "Längste akzeptable SEO-Beschreibung", + reportMissing: "Einträge ohne SEO-Beschreibung melden", + reportMissingHelp: "Schalte das ab, wenn deine Templates immer eine eigene Beschreibung ausgeben.", + pageSize: "Einträge pro Lauf", + schedule: "Zeitplan (Cron, UTC)", + collectionStale: "{collection}: Monate, bis ein Eintrag veraltet ist (leer: {months}, 0: nie)", + collectionDraft: "{collection}: Monate, bis ein Entwurf vergessen ist (leer: {months}, 0: nie)", + collectionSkip: "{collection}: von der Prüfung ausnehmen", + save: "Speichern", + saved: "Einstellungen gespeichert.", + notSaved: "Einstellungen nicht gespeichert. {reason}", + adminOnly: "Nur Administratoren können diese Einstellungen ändern.", + + hitOverduePublish: "Geplant für {date}, aber noch {status}.", + hitOverdueUpdate: "Die für {date} geplanten Änderungen wurden nie veröffentlicht.", + hitMissingDescription: "Veröffentlicht ohne SEO-Beschreibung.", + hitMissingFallback: "Keine SEO-Beschreibung; Templates zeigen oft stattdessen das Feld „{field}“.", + hitDescriptionLength: "Die SEO-Beschreibung hat {length} Zeichen; empfohlen sind {min}–{max}.", + hitStale: "Seit {since} nicht bearbeitet.", + hitStaleDraft: "Entwurf seit {since} nicht bearbeitet.", + statusDraft: "ein Entwurf", + statusScheduled: "geplant", + statusOther: "{status}", +}; + +const catalogues = { en, de } as const; export type Lang = keyof typeof catalogues; export type Params = Record; -/** The catalogue for an admin locale. English until a catalogue exists for it. */ -export function langOf(_locale: string | undefined): Lang { - return "en"; +/** The catalogue for an admin locale: `de`, `de-AT` and `de-CH` read German, everything else English. */ +export function langOf(locale: string | undefined): Lang { + return locale?.toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; } export function t(lang: Lang, key: MessageKey, params: Params = {}): string { diff --git a/packages/content-freshness/tests/i18n.test.ts b/packages/content-freshness/tests/i18n.test.ts new file mode 100644 index 0000000..6af82a3 --- /dev/null +++ b/packages/content-freshness/tests/i18n.test.ts @@ -0,0 +1,36 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { langOf } from "../src/i18n.js"; +import { PANEL_ID } from "../src/panel.js"; +import { newHost, runSweep, undescribed } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +describe("the admin language", () => { + it("is German for every German locale and English otherwise", () => { + expect(["de", "de-AT", "de_CH"].map(langOf)).toEqual(["de", "de", "de"]); + expect(["en", "fr", undefined].map(langOf)).toEqual(["en", "en", "en"]); + }); + + it("carries into the report, the widget and the panel", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "auf-deutsch"); + await runSweep(host); + + const report = JSON.stringify((await host.admin.loadPage("/report", { locale: "de" })).blocks); + expect(report).toContain("Aktualität der Inhalte"); + expect(report).toContain("Veröffentlicht ohne SEO-Beschreibung."); + + const widget = JSON.stringify((await host.admin.loadWidget("summary", { locale: "de" })).blocks); + expect(widget).toContain("Bericht öffnen"); + + const panel = JSON.stringify((await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id, { locale: "de" })).blocks); + expect(panel).toContain("Ignorieren"); + }); +}); From 33381dcaa82961f7729fb425525902bc10c493cd Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 25 Sep 2026 23:55:31 +0200 Subject: [PATCH 7/9] docs(content-freshness): the report, the panel and settings per collection --- packages/content-freshness/README.md | 33 +++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index a4b4fbe..d9d0725 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -20,11 +20,29 @@ 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. -The report holds one row per entry that needs attention, most urgent first. -It shows the current state, not a history: an entry is checked again when it -is published, unpublished, scheduled, unscheduled or restored, and leaves the -report as soon as it is trashed or deleted. Other edits show up with the next -audit, which also removes rows of entries that no longer exist. +## Where the findings show up + +- **The report** (Content freshness in the admin menu) lists every entry that + needs attention, most urgent first, 25 per page, filtered by collection and + priority. Each entry links straight into its editor. +- **The Freshness panel** in the entry editor shows that entry's findings. It + checks the entry each time it opens, and it is where a finding is set aside: + **Mark as reviewed** holds a stale entry or a forgotten draft back until its + threshold runs out again, so a page that is still correct needs no edit to + quiet the report. **Ignore** holds a description finding back for good. A + missed schedule cannot be set aside. **Undo** brings a finding back. + Authors see the panel on their own entries; the report is for editors and + above. +- **The dashboard widget** counts the entries by priority and says how many + of them are yours. + +The report shows the current state, not a history. An entry is checked again +when it is published, unpublished, scheduled, unscheduled or restored, and +whenever its panel opens; it leaves the report as soon as it is trashed or +deleted. Other edits show up with the next audit, which also removes the rows +of entries that no longer exist. + +The admin follows the administrator's language: English and German. Each run audits one page of one collection, and an audit carries on in follow-up runs until it is done, one per firing of the site's cron trigger, @@ -40,7 +58,9 @@ site whose routes differ, and nothing else says so. `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. +access** and no `content:write`: the plugin reports, it never edits an entry. +What it stores (findings and the findings you set aside) stays in its own +plugin storage. ## Install @@ -74,6 +94,7 @@ entry. Without one, the audit is scheduled and never runs. | Report entries without an SEO description | on | Off for a site whose templates always render a description. The length check still runs. | | Entries per run | 50 | At most 98: D1 binds at most 100 parameters per statement, and storage adds two of its own. The number of calls per run does not grow with it. | | Schedule | `0 4 * * *` | A cron expression, in UTC. One the scheduler rejects is not saved. | +| Per collection | none | Months for stale entries and forgotten drafts of one collection (empty: the site's value, 0: never), or leave the collection out of the audit. | ## Develop From d4395117a35280492f06151051f9417ada6f45ae Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 00:15:14 +0200 Subject: [PATCH 8/9] fix(content-freshness): findings from the review of the panel and the report - A per-collection threshold could not be cleared: the admin drops an emptied number field from the submission, so the key never arrived. For every collection the form showed, a missing month field now means the field was emptied. - An override that is not a number became 0, which switches the rule off. It is dropped instead. - A filter on a collection deleted since the page was shown produced a select without a matching option, and the host rejected the report. It falls back to all collections. - EmDash reloads an open editor panel after every save, autosaves included, and each reload wrote the entry's row. A check now reads the row and writes only when it changed. - Set-aside findings were never removed. Deleting an entry for good removes them (trashing keeps them for a restore), and a review that ran out is dropped the next time the entry is checked. - Without `schema:read` the collection list is empty, and a sweep over no collections would have cleaned up every row. No sweep starts then. - The widget counted the viewer's entries even when there was nothing to show. - "Scheduled for X but still scheduled." now reads "Scheduled to go live on X, but it never did.", in both languages. - The sweep's budget comment names every call again. --- packages/content-freshness/src/check.ts | 29 +++++++--- packages/content-freshness/src/describe.ts | 8 +-- packages/content-freshness/src/findings.ts | 37 +++++++++++- packages/content-freshness/src/hooks.ts | 10 ++-- packages/content-freshness/src/i18n.ts | 10 +--- packages/content-freshness/src/plugin.ts | 5 +- packages/content-freshness/src/report.ts | 15 +++-- packages/content-freshness/src/rules.ts | 2 +- packages/content-freshness/src/settings.ts | 33 +++++++++-- packages/content-freshness/src/sweep.ts | 13 +++-- .../content-freshness/tests/budget.test.ts | 7 +++ .../tests/cron-names.test.ts | 16 ++++++ .../tests/dismissals.test.ts | 2 +- .../content-freshness/tests/panel.test.ts | 57 ++++++++++++++++++- .../tests/report-paging.test.ts | 2 +- .../content-freshness/tests/report.test.ts | 10 ++++ .../content-freshness/tests/rules.test.ts | 8 +++ .../content-freshness/tests/settings.test.ts | 14 ++++- 18 files changed, 225 insertions(+), 53 deletions(-) diff --git a/packages/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts index 51bdbc9..02e8934 100644 --- a/packages/content-freshness/src/check.ts +++ b/packages/content-freshness/src/check.ts @@ -7,13 +7,14 @@ import type { PluginContext } from "emdash/plugin"; import { dismissalFor, + isSetAside, partition, readDismissals, writeDismissals, type Dismissal, type EntryDismissals, } from "./dismissals.js"; -import { evaluateEntries, forget, store, type EntryRef } from "./findings.js"; +import { evaluateEntries, forget, storeOne, type EntryFindings, type EntryRef } from "./findings.js"; import { findingId } from "./ids.js"; import { evaluateEntry, type Hit, type Rule } from "./rules.js"; import { readSettings, thresholdsFor } from "./settings.js"; @@ -33,37 +34,47 @@ export interface PanelView { /** * Apply `action`, check the entry, store its row and return what the panel - * shows. Four `ctx` calls, five with an action. + * shows. Four `ctx` calls; one more for a changed row, one more for an + * action or an expired review. */ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: PanelAction): Promise { if (!ctx.content) return { active: [], setAside: [] }; - const [settings, entry, stored] = await Promise.all([ + const [settings, entry, stored, existing] = await Promise.all([ readSettings(ctx), ctx.content.get(ref.collection, ref.id), readDismissals(ctx, ref.collection, ref.id), + ctx.storage.findings.get(findingId(ref.collection, ref.id)) as Promise, ]); const thresholds = thresholdsFor(settings, ref.collection); if (!entry || thresholds.skip) { - await forget(ctx, ref); + if (existing) await forget(ctx, ref); return { active: [], setAside: [], skipped: thresholds.skip }; } const now = new Date(); - let dismissals: EntryDismissals = stored ?? { collection: ref.collection, entryId: ref.id, rules: {} }; + const rules = { ...stored?.rules }; + // Reviews that have run out are dropped whenever the entry is checked. + let changed = false; + for (const rule of Object.keys(rules) as Rule[]) { + if (!isSetAside({ collection: ref.collection, entryId: ref.id, rules }, rule, now)) { + delete rules[rule]; + changed = true; + } + } if (action) { - const rules = { ...dismissals.rules }; if (action.kind === "undo") { delete rules[action.rule]; } else { const dismissal = dismissalFor(action.rule, thresholds, now, action.by); if (dismissal) rules[action.rule] = dismissal; } - dismissals = { ...dismissals, rules }; - await writeDismissals(ctx, dismissals); + changed = true; } + const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules }; + if (changed) await writeDismissals(ctx, dismissals); const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); - await store(ctx, evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed)); + await storeOne(ctx, evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed), existing); const { active, setAside } = partition(evaluateEntry(entry, thresholds, now), dismissals, now); return { active, setAside: setAside.map((hit) => ({ hit, dismissal: dismissals.rules[hit.rule]! })) }; } diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts index 72fd572..97e7c10 100644 --- a/packages/content-freshness/src/describe.ts +++ b/packages/content-freshness/src/describe.ts @@ -6,19 +6,13 @@ import { t, type Lang } from "./i18n.js"; import type { Hit, Severity } from "./rules.js"; -function statusText(lang: Lang, status: string | number | undefined): string { - if (status === "draft") return t(lang, "statusDraft"); - if (status === "scheduled") return t(lang, "statusScheduled"); - return t(lang, "statusOther", { status: String(status ?? "") }); -} - export function describeHit(lang: Lang, hit: Hit): string { const { params } = hit; switch (hit.rule) { case "overdue-schedule": return params.kind === "update" ? t(lang, "hitOverdueUpdate", { date: params.date ?? "" }) - : t(lang, "hitOverduePublish", { date: params.date ?? "", status: statusText(lang, params.status) }); + : t(lang, "hitOverduePublish", { date: params.date ?? "" }); case "missing-description": return params.field ? t(lang, "hitMissingFallback", { field: params.field }) diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts index 81e6d22..cd98a28 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -76,6 +76,31 @@ export function evaluateEntries( return result; } +/** + * Store one entry's result, writing only when it differs from `existing`. + * EmDash reloads an open editor panel after every save, autosaves + * included, and each reload checks the entry: without the comparison every + * keystroke that reached a save would also write a row. `seenIn` is left out + * of the comparison; a row a sweep has not reached yet gets its own write + * when it does. + */ +export async function storeOne(ctx: PluginContext, evaluated: Evaluated, existing: EntryFindings | null): Promise { + const [write] = evaluated.writes; + const [clear] = evaluated.clears; + if (write) { + if (existing && sameFinding(existing, write.data)) return; + await ctx.storage.findings.put(write.id, write.data); + } else if (clear && existing) { + await ctx.storage.findings.delete(clear); + } +} + +function sameFinding(a: EntryFindings, b: EntryFindings): boolean { + const { seenIn: _a, ...left } = a; + const { seenIn: _b, ...right } = b; + return JSON.stringify(left) === JSON.stringify(right); +} + /** One `putMany` and one `deleteMany` at most, whatever the number of entries. */ export async function store(ctx: PluginContext, evaluated: Evaluated): Promise { if (evaluated.writes.length > 0) await ctx.storage.findings.putMany(evaluated.writes); @@ -87,7 +112,13 @@ export interface EntryRef { id: string; } -/** Remove an entry's row: it was trashed or deleted. */ -export async function forget(ctx: PluginContext, ref: EntryRef): Promise { - await ctx.storage.findings.delete(findingId(ref.collection, ref.id)); +/** + * Remove an entry's row: it was trashed or deleted. What an editor set aside + * goes too once the entry is deleted for good; a trashed entry keeps it, so + * a restore brings the entry back as it was. + */ +export async function forget(ctx: PluginContext, ref: EntryRef, permanent = false): Promise { + const id = findingId(ref.collection, ref.id); + await ctx.storage.findings.delete(id); + if (permanent) await ctx.storage.dismissals.delete(id); } diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts index 2220580..90d847d 100644 --- a/packages/content-freshness/src/hooks.ts +++ b/packages/content-freshness/src/hooks.ts @@ -20,9 +20,11 @@ export function changedEntry(event: unknown): EntryRef | null { return typeof id === "string" ? { collection, id } : null; } -/** The entry a delete event is about: `{ id, collection }`. */ -export function deletedEntry(event: unknown): EntryRef | null { +/** The entry a delete event is about, `{ id, collection, permanent }`, and whether it is gone for good. */ +export function deletedEntry(event: unknown): (EntryRef & { permanent: boolean }) | null { if (typeof event !== "object" || event === null) return null; - const { id, collection } = event as { id?: unknown; collection?: unknown }; - return typeof id === "string" && typeof collection === "string" ? { collection, id } : null; + const { id, collection, permanent } = event as { id?: unknown; collection?: unknown; permanent?: unknown }; + return typeof id === "string" && typeof collection === "string" + ? { collection, id, permanent: permanent === true } + : null; } diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts index 7ee1e06..b42cf77 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -70,16 +70,13 @@ const en = { notSaved: "Settings not saved. {reason}", adminOnly: "Only an administrator can change these settings.", - hitOverduePublish: "Scheduled for {date} but still {status}.", + hitOverduePublish: "Scheduled to go live on {date}, but it never did.", hitOverdueUpdate: "Changes scheduled for {date} were never published.", hitMissingDescription: "Published without an SEO description.", hitMissingFallback: "No SEO description; templates often show the “{field}” field instead.", hitDescriptionLength: "SEO description is {length} characters; aim for {min}–{max}.", hitStale: "Not touched since {since}.", hitStaleDraft: "Draft untouched since {since}.", - statusDraft: "a draft", - statusScheduled: "scheduled", - statusOther: "{status}", } satisfies Record; export type MessageKey = keyof typeof en; @@ -144,16 +141,13 @@ const de: Record = { notSaved: "Einstellungen nicht gespeichert. {reason}", adminOnly: "Nur Administratoren können diese Einstellungen ändern.", - hitOverduePublish: "Geplant für {date}, aber noch {status}.", + hitOverduePublish: "Sollte am {date} live gehen, ist es aber nie.", hitOverdueUpdate: "Die für {date} geplanten Änderungen wurden nie veröffentlicht.", hitMissingDescription: "Veröffentlicht ohne SEO-Beschreibung.", hitMissingFallback: "Keine SEO-Beschreibung; Templates zeigen oft stattdessen das Feld „{field}“.", hitDescriptionLength: "Die SEO-Beschreibung hat {length} Zeichen; empfohlen sind {min}–{max}.", hitStale: "Seit {since} nicht bearbeitet.", hitStaleDraft: "Entwurf seit {since} nicht bearbeitet.", - statusDraft: "ein Entwurf", - statusScheduled: "geplant", - statusOther: "{status}", }; const catalogues = { en, de } as const; diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index 6d01fb8..fe8e00c 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -108,7 +108,7 @@ const plugin = { const ref = deletedEntry(event); if (!ref) return; try { - await forget(ctx, ref); + await forget(ctx, ref, ref.permanent); } catch (error) { ctx.log.warn(`Could not drop ${ref.collection}/${ref.id}: ${String(error)}`); } @@ -205,7 +205,8 @@ async function saveSettings( }; } - const next = { settings: applyForm(stored.settings, values), scheduledAs: stored.scheduledAs }; + const shown = collections.map((collection) => collection.slug); + const next = { settings: applyForm(stored.settings, values, shown), scheduledAs: stored.scheduledAs }; // Schedule first: an expression the scheduler rejects is never stored. try { await ensureScheduled(ctx, next); diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index eb02f53..f60708e 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -134,11 +134,7 @@ export async function buildWidget( lang: Lang, userId?: string, ): Promise { - const [counts, collections, yours] = await Promise.all([ - countFindings(ctx), - listCollections(ctx), - userId ? ctx.storage.findings.count({ authorId: userId }) : Promise.resolve(0), - ]); + const [counts, collections] = await Promise.all([countFindings(ctx), listCollections(ctx)]); const banner = urlPatternBanner(lang, collections, false); if (counts.total === 0) { @@ -156,6 +152,7 @@ export async function buildWidget( }; } + const yours = userId ? await ctx.storage.findings.count({ authorId: userId }) : 0; return { blocks: [ ...banner, @@ -257,11 +254,17 @@ export async function buildReportPage( lang: Lang, view: ReportView = FIRST_VIEW, ): Promise { - const [collections, counts, { page, later }] = await Promise.all([ + const [collections, counts, requested] = await Promise.all([ listCollections(ctx), countFindings(ctx), findingsPage(ctx, view), ]); + // A filter can name a collection deleted since the page was shown. The + // select would then have no option for it, and the host rejects the whole + // response, so the report falls back to all collections. + const known = view.collection === null || collections.some((item) => item.slug === view.collection); + if (!known) view = { ...view, collection: null, cursor: null }; + const { page, later } = known ? requested : await findingsPage(ctx, view); const banner = urlPatternBanner(lang, collections, true); const auditNow: ButtonElement = { type: "button", diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index 097e0fe..e319bfa 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -89,7 +89,7 @@ export function evaluateEntry(entry: PluginContentItem, thresholds: Thresholds, hits.push({ rule: "overdue-schedule", severity: "high", - params: { date: day(scheduledAt), kind: published ? "update" : "publish", status: entry.status }, + params: { date: day(scheduledAt), kind: published ? "update" : "publish" }, }); } diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 3037d97..f312779 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -123,21 +123,39 @@ function normalizeOverrides(source: unknown): Record if (!SLUG.test(slug) || typeof raw !== "object" || raw === null) continue; const { staleMonths, draftMonths, skip } = raw as Record; const own: CollectionOverride = {}; - if (isSet(staleMonths)) own.staleMonths = clampNumber(staleMonths, 0, 120, 0); - if (isSet(draftMonths)) own.draftMonths = clampNumber(draftMonths, 0, 120, 0); + const stale = months(staleMonths); + const draft = months(draftMonths); + if (stale !== undefined) own.staleMonths = stale; + if (draft !== undefined) own.draftMonths = draft; if (skip === true) own.skip = true; if (Object.keys(own).length > 0) overrides[slug] = own; } return overrides; } -/** A form's empty number field arrives as `undefined`, `null` or `""`: no override. */ +/** + * An override's months, or nothing. A value that is not a number is dropped + * rather than clamped, because 0 would switch the rule off. + */ +function months(value: unknown): number | undefined { + if (!isSet(value)) return undefined; + const n = Number(value); + return Number.isFinite(n) ? clampNumber(n, 0, 120, 0) : undefined; +} + +/** An empty number field: no override. */ function isSet(value: unknown): boolean { return value !== undefined && value !== null && !(typeof value === "string" && !value.trim()); } -/** `current` with the submitted form values applied. Unknown keys are ignored. */ -export function applyForm(current: Settings, values: Record): Settings { +/** + * `current` with the submitted form values applied. Unknown keys are ignored. + * + * `shown` names the collections whose fields the form carried. The admin + * drops an emptied number field from the submission altogether, so for those + * collections a missing month field means the admin cleared it. + */ +export function applyForm(current: Settings, values: Record, shown: string[] = []): Settings { const merged: Record = { ...current }; for (const [key] of NUMBERS) { if (values[key] !== undefined) merged[key] = values[key]; @@ -149,6 +167,11 @@ export function applyForm(current: Settings, values: Record): S const collections: Record> = {}; for (const [slug, own] of Object.entries(current.collections)) collections[slug] = { ...own }; + for (const slug of shown) { + for (const setting of ["staleMonths", "draftMonths"] as const) { + if (!(collectionField(slug, setting) in values)) delete collections[slug]?.[setting]; + } + } for (const [key, value] of Object.entries(values)) { const match = COLLECTION_FIELD.exec(key); if (!match) continue; diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index 5d2d6f8..5c01f22 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -3,9 +3,12 @@ * * A sandboxed plugin gets ten subrequests per invocation and every `ctx` * call is one, so a run does one bounded slice and hands over to a - * follow-up. A run costs two KV reads, one `content.list`, at most one - * `putMany` and one `deleteMany`, one KV write and one schedule, plus the - * collection list when a sweep starts. + * follow-up. An audit run costs two KV reads (settings, state), one + * `content.list`, one `dismissals.getMany`, at most one `putMany` and one + * `deleteMany`, one KV write and one schedule, plus the collection list when + * a sweep starts: nine at most. A cleanup run costs six, its log line + * included. `tests/budget.test.ts` counts every path; run it after adding a + * `ctx` call. * * When the last collection is done, the sweep removes every row it did not * write or confirm: entries that were trashed or deleted, and collections @@ -41,7 +44,9 @@ const ABANDON_AFTER_MS = 7 * 24 * 60 * 60 * 1000; export async function runAudit(ctx: PluginContext, event: ChainEvent): Promise { const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; if (!starts && !isFollowUp(CHAIN, event.name)) return; - if (!ctx.content) return; + // Without `schema:read` the collection list comes back empty, and a sweep + // over no collections would go straight to cleaning up every row. + if (!ctx.content || !ctx.schema) return; const [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); const now = new Date(); diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts index 04e1af0..c035a8e 100644 --- a/packages/content-freshness/tests/budget.test.ts +++ b/packages/content-freshness/tests/budget.test.ts @@ -159,6 +159,13 @@ describe("admin requests", () => { return runtime; } + it("an empty dashboard spends nothing on counting the viewer's entries", async () => { + host = await newHost(); + const runtime = host; + const calls = await bridgeCalls(() => runtime.admin.loadWidget("summary")); + expect(calls.filter((call) => call === "storageCount")).toHaveLength(3); + }); + it("the first dashboard view, which also schedules the audit", async () => { host = await siteWithFindings(); const runtime = host; diff --git a/packages/content-freshness/tests/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts index dd59e33..be36909 100644 --- a/packages/content-freshness/tests/cron-names.test.ts +++ b/packages/content-freshness/tests/cron-names.test.ts @@ -76,3 +76,19 @@ describe("cron task names", () => { expect(warns).toHaveLength(0); }); }); + +describe("a context without schema:read", () => { + it("starts no sweep, which would clean up every row", async () => { + const queried: string[] = []; + const ctx = { + kv: { get: async () => null, set: async () => {} }, + content: {}, + storage: { findings: { query: async () => (queried.push("query"), { items: [], hasMore: false }) } }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + } as unknown as PluginContext; + + await cronHandler()({ name: "audit", scheduledAt: "2026-09-20T03:00:00.000Z" }, ctx); + + expect(queried).toEqual([]); + }); +}); diff --git a/packages/content-freshness/tests/dismissals.test.ts b/packages/content-freshness/tests/dismissals.test.ts index 923443a..1db27f2 100644 --- a/packages/content-freshness/tests/dismissals.test.ts +++ b/packages/content-freshness/tests/dismissals.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_THRESHOLDS, type Hit } from "../src/rules.js"; const NOW = new Date("2026-09-25T12:00:00.000Z"); const stale: Hit = { rule: "stale", severity: "medium", params: { since: "2025-01-01" } }; const missing: Hit = { rule: "missing-description", severity: "medium", params: {} }; -const overdue: Hit = { rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }; +const overdue: Hit = { rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }; function dismissed(rules: EntryDismissals["rules"]): EntryDismissals { return { collection: "pages", entryId: "01A", rules }; diff --git a/packages/content-freshness/tests/panel.test.ts b/packages/content-freshness/tests/panel.test.ts index 9f0c725..8d271bb 100644 --- a/packages/content-freshness/tests/panel.test.ts +++ b/packages/content-freshness/tests/panel.test.ts @@ -4,6 +4,7 @@ import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it } from "vitest"; import { PANEL_ID, renderPanel, SET_ASIDE_ACTION, UNDO_ACTION } from "../src/panel.js"; +import { bridgeCalls } from "./bridge-calls.js"; import { finding, newHost, runSweep, undescribed } from "./host.js"; let host: PluginRuntimeTestHost | undefined; @@ -68,10 +69,64 @@ describe("the Freshness panel", () => { }); }); +describe("the panel on reload", () => { + it("writes nothing when the entry's findings have not changed", async () => { + // EmDash reloads an open panel after every save, autosaves included. + host = await newHost(); + const runtime = host; + const entry = await undescribed(runtime, "posts", "reloaded"); + await runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + const calls = await bridgeCalls(() => runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id)); + + expect(calls.filter((call) => /Put|Delete/.test(call))).toEqual([]); + }); + + it("drops a review that has run out", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "expired-review"); + const id = `posts:${entry.id}`; + await host.fixtures.plugin.storage("dismissals", id, { + collection: "posts", + entryId: entry.id, + rules: { stale: { until: "2026-01-01T00:00:00.000Z", by: null, at: "2025-01-01T00:00:00.000Z" } }, + }); + + await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + expect(await host.inspect.storage.get("dismissals", id)).toBeNull(); + }); +}); + +describe("what is set aside", () => { + async function ignoredEntry(slug: string) { + const runtime = await newHost(); + const entry = await undescribed(runtime, "posts", slug); + await runtime.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { + value: { rule: "missing-description" }, + }); + return { runtime, id: `posts:${entry.id}`, entry }; + } + + it("stays while the entry is only in the trash", async () => { + const { runtime, id, entry } = await ignoredEntry("trashed-keeps"); + host = runtime; + await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: false }); + expect(await runtime.inspect.storage.get("dismissals", id)).not.toBeNull(); + }); + + it("goes when the entry is deleted for good", async () => { + const { runtime, id, entry } = await ignoredEntry("deleted-drops"); + host = runtime; + await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: true }); + expect(await runtime.inspect.storage.get("dismissals", id)).toBeNull(); + }); +}); + describe("the panel's controls", () => { it("offer no way to set aside a missed schedule", () => { const { blocks } = renderPanel("en", { - active: [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }], + active: [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }], setAside: [], }); expect(sections(blocks)[0]?.accessory).toBeUndefined(); diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts index 8a42402..5de1434 100644 --- a/packages/content-freshness/tests/report-paging.test.ts +++ b/packages/content-freshness/tests/report-paging.test.ts @@ -31,7 +31,7 @@ function row(entryId: string, rank: number): EntryFindings { rank, hits: rank === 0 - ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }] + ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }] : [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], entryUpdatedAt: "2025-01-01T00:00:00.000Z", seenIn: "2026-09-25T04:00:00.000Z", diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index 7e9a42a..e1e81c7 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -1,4 +1,5 @@ import type { ActionsBlock, Block, ButtonElement, ContextBlock, FormBlock, SectionBlock, SelectElement } from "@emdash-cms/blocks"; +import { validateBlocks } from "@emdash-cms/blocks/server"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; @@ -112,6 +113,15 @@ describe("filters and paging", () => { expect(viewFrom(next?.value)).toEqual({ ...filtered, cursor: "next-page" }); }); + it("fall back to all collections when the filtered one is gone", async () => { + // The host rejects a select whose initial value matches no option. + const gone: ReportView = { collection: "events", rank: null, cursor: "stale" }; + const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, "en", gone); + expect(validateBlocks(blocks).valid).toBe(true); + expect(viewFrom(selects(blocks)[0]?.initial_value)).toEqual({ collection: null, rank: null, cursor: null }); + expect(sections(blocks)).toHaveLength(1); + }); + it("read a view it did not write as the first page", () => { expect(viewFrom("not json")).toEqual({ collection: null, rank: null, cursor: null }); expect(viewFrom({ collection: 5, rank: 9, cursor: "" })).toEqual({ collection: null, rank: null, cursor: null }); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index e61041e..5edfff8 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { describeHit } from "../src/describe.js"; import { DEFAULT_THRESHOLDS, evaluateEntry, parseDate, subtractMonths } from "../src/rules.js"; import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; @@ -56,6 +57,13 @@ describe("evaluateEntry", () => { expect(overdue).toMatchObject({ severity: "high", params: { date: "2026-08-01", kind: "publish" } }); }); + it("says that a scheduled entry never went live, whatever its status", () => { + for (const status of ["draft", "scheduled"]) { + const [hit] = evaluateEntry(entry({ status, scheduledAt: "2026-08-01T00:00:00.000Z" }), DEFAULT_THRESHOLDS, NOW); + expect(describeHit("en", hit!)).toBe("Scheduled to go live on 2026-08-01, but it never did."); + } + }); + it("flags scheduled changes to a published entry that never went live", () => { // EmDash keeps `scheduledAt` on a published entry for scheduled draft // changes and clears it once they publish, so a past one was missed. diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 22b1145..9695f90 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -46,7 +46,19 @@ describe("settings per collection", () => { it("fall back to the site's value when the field is emptied", () => { const current = applyForm(DEFAULT_SETTINGS, { "collection:pages:staleMonths": 24 }); - const next = applyForm(current, { "collection:pages:staleMonths": "" }); + // The admin leaves an emptied number field out of the submission. + const next = applyForm(current, { "collection:pages:skip": false }, ["pages"]); + expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); + expect(next.collections).toEqual({}); + }); + + it("keep the overrides of collections the form did not show", () => { + const current = applyForm(DEFAULT_SETTINGS, { "collection:archive:staleMonths": 36 }); + expect(thresholdsFor(applyForm(current, {}, ["pages"]), "archive").staleMonths).toBe(36); + }); + + it("drop an override that is not a number instead of switching the rule off", () => { + const next = normalizeSettings({ collections: { pages: { staleMonths: "abc", draftMonths: Number.NaN } } }); expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); expect(next.collections).toEqual({}); }); From 6d4f25db599ad93b259777eeba26f5368d4d6b98 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 00:28:18 +0200 Subject: [PATCH 9/9] docs(content-freshness): 99 tests on every EmDash release from 0.39.0 to 0.40.1 --- packages/content-freshness/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index d9d0725..2d23738 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -109,10 +109,10 @@ pnpm build | EmDash | Plugin test suite | | ------ | ----------------- | -| 0.40.1 | 65 passed | -| 0.40.0 | 65 passed | -| 0.39.1 | 65 passed | -| 0.39.0 | 65 passed | +| 0.40.1 | 99 passed | +| 0.40.0 | 99 passed | +| 0.39.1 | 99 passed | +| 0.39.0 | 99 passed | Declared range: `emdash >=0.39.0`, with no upper bound. The table covers **every EmDash release since the floor**. 0.39.0 is the floor because the