diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 2adaff2..2d23738 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 @@ -72,8 +92,9 @@ 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. | +| 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 @@ -88,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 diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc index 39a285c..f4e0841 100644 --- a/packages/content-freshness/emdash-plugin.jsonc +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -33,7 +33,10 @@ // 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"]] }, + // 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..02e8934 --- /dev/null +++ b/packages/content-freshness/src/check.ts @@ -0,0 +1,81 @@ +/** + * 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, + isSetAside, + partition, + readDismissals, + writeDismissals, + type Dismissal, + type EntryDismissals, +} from "./dismissals.js"; +import { evaluateEntries, forget, storeOne, type EntryFindings, type EntryRef } from "./findings.js"; +import { findingId } from "./ids.js"; +import { evaluateEntry, type Hit, type Rule } from "./rules.js"; +import { readSettings, thresholdsFor } from "./settings.js"; + +export interface PanelAction { + kind: "set-aside" | "undo"; + rule: Rule; + by: string | null; +} + +export interface PanelView { + active: Hit[]; + setAside: Array<{ hit: Hit; dismissal: Dismissal }>; + /** The entry's collection is left out of the audit. */ + skipped?: boolean; +} + +/** + * Apply `action`, check the entry, store its row and return what the panel + * shows. 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, 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) { + if (existing) await forget(ctx, ref); + return { active: [], setAside: [], skipped: thresholds.skip }; + } + + const now = new Date(); + 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) { + if (action.kind === "undo") { + delete rules[action.rule]; + } else { + const dismissal = dismissalFor(action.rule, thresholds, now, action.by); + if (dismissal) rules[action.rule] = dismissal; + } + changed = true; + } + const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules }; + if (changed) await writeDismissals(ctx, dismissals); + + const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); + 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 8fc87af..97e7c10 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 type { Hit } from "./rules.js"; +import { t, type Lang } from "./i18n.js"; +import type { Hit, Severity } from "./rules.js"; -export function describeHit(hit: Hit): string { +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 ?? "" }); 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); } } + +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 9fd6318..cd98a28 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -10,13 +10,19 @@ 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"; + 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; @@ -24,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 }>; @@ -34,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; @@ -55,7 +63,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, @@ -66,8 +76,49 @@ 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); if (evaluated.clears.length > 0) await ctx.storage.findings.deleteMany(evaluated.clears); } + +export interface EntryRef { + collection: string; + id: string; +} + +/** + * Remove an entry's row: it was trashed or deleted. What an editor set aside + * goes too once the entry is deleted for good; a trashed entry keeps it, so + * a restore brings the entry back as it was. + */ +export async function forget(ctx: PluginContext, ref: EntryRef, permanent = false): Promise { + const id = findingId(ref.collection, ref.id); + await ctx.storage.findings.delete(id); + if (permanent) await ctx.storage.dismissals.delete(id); +} diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts index 64eba5b..90d847d 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 { @@ -27,25 +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; -} - -/** 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)); + 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 new file mode 100644 index 0000000..b42cf77 --- /dev/null +++ b/packages/content-freshness/src/i18n.ts @@ -0,0 +1,172 @@ +/** + * The plugin's own message catalogue. + * + * Block Kit plugins localize themselves: the host passes the administrator's + * locale in `routeCtx.ui` and does not consume plugin catalogues. English is + * the source and the fallback; `de` is typed against its keys, so a missing + * German entry fails the typecheck. Manifest strings (page labels, widget and + * panel titles) stay static. Logs stay English. + */ + +type Plural = { one: string; other: string }; +type Message = string | Plural; + +const en = { + reportTitle: "Content freshness", + priorityHigh: "Urgent", + priorityMedium: "Should fix", + priorityLow: "Nice to fix", + statEntries: "Entries", + auditRunning: "Audit in progress since {date}", + auditLast: "Last audit {date}", + auditPending: "First audit pending", + nothingTitle: "Nothing needs attention", + nothingText: "No stale entries, no missing descriptions, no overdue schedules.", + noMatch: "No entry matches this filter.", + filterCollection: "Collection", + filterPriority: "Priority", + filterAll: "All", + open: "Open", + nextPage: "Next page", + firstPage: "First page", + auditNow: "Audit now", + auditContinues: "The running audit continues with the next scheduled run.", + auditStarts: "The audit starts with the next scheduled run.", + urlPatternTitle: "No URL pattern: {names}", + urlPatternDetail: + "EmDash links their entries as /{collection}/{slug} in the sitemap and the admin, which is wrong unless the site uses exactly those routes. Set a URL pattern under Content Types, or switch off routing for a collection that has no pages of its own.", + urlPatternShort: "Sitemap links for these collections may be broken.", + + widgetClear: "Everything current", + widgetLastAudit: "Last audit {date}.", + widgetPending: "Runs on the next scheduled audit.", + widgetYours: { one: "{count} of them is yours", other: "{count} of them are yours" }, + openReport: "Open report", + + panelClear: "Nothing to fix here.", + panelSkipped: "This collection is left out of the freshness audit.", + markReviewed: "Mark as reviewed", + ignore: "Ignore", + undo: "Undo", + reviewedUntil: "Marked as reviewed; checked again from {date}.", + ignored: "Ignored.", + dismissedHeader: "Set aside", + + settingsTitle: "Freshness settings", + settingsIntro: "These thresholds apply to every collection unless a collection sets its own at the end of the form.", + staleMonths: "Months before a published entry counts as stale", + draftMonths: "Months before a draft counts as forgotten", + descriptionMin: "Shortest acceptable SEO description", + descriptionMax: "Longest acceptable SEO description", + reportMissing: "Report entries without an SEO description", + reportMissingHelp: "Switch off if your templates always render a description of their own.", + pageSize: "Entries per run", + schedule: "Schedule (cron, UTC)", + collectionStale: "{collection}: months before an entry is stale (empty: {months}, 0: never)", + collectionDraft: "{collection}: months before a draft is forgotten (empty: {months}, 0: never)", + collectionSkip: "{collection}: leave out of the audit", + save: "Save", + saved: "Settings saved.", + notSaved: "Settings not saved. {reason}", + adminOnly: "Only an administrator can change these settings.", + + hitOverduePublish: "Scheduled to go live on {date}, but it never did.", + hitOverdueUpdate: "Changes scheduled for {date} were never published.", + hitMissingDescription: "Published without an SEO description.", + hitMissingFallback: "No SEO description; templates often show the “{field}” field instead.", + hitDescriptionLength: "SEO description is {length} characters; aim for {min}–{max}.", + hitStale: "Not touched since {since}.", + hitStaleDraft: "Draft untouched since {since}.", +} satisfies Record; + +export type MessageKey = keyof typeof en; + +const de: Record = { + reportTitle: "Aktualität der Inhalte", + priorityHigh: "Dringend", + priorityMedium: "Bald erledigen", + priorityLow: "Bei Gelegenheit", + statEntries: "Einträge", + auditRunning: "Prüfung läuft seit {date}", + auditLast: "Letzte Prüfung {date}", + auditPending: "Erste Prüfung steht aus", + nothingTitle: "Alles aktuell", + nothingText: "Keine veralteten Einträge, keine fehlenden Beschreibungen, keine verpassten Termine.", + noMatch: "Kein Eintrag passt zu diesem Filter.", + filterCollection: "Kollektion", + filterPriority: "Priorität", + filterAll: "Alle", + open: "Öffnen", + nextPage: "Nächste Seite", + firstPage: "Erste Seite", + auditNow: "Jetzt prüfen", + auditContinues: "Die laufende Prüfung geht mit dem nächsten geplanten Lauf weiter.", + auditStarts: "Die Prüfung startet mit dem nächsten geplanten Lauf.", + urlPatternTitle: "Kein URL-Muster: {names}", + urlPatternDetail: + "EmDash verlinkt ihre Einträge in der Sitemap und im Admin als /{collection}/{slug}. Das stimmt nur, wenn die Website genau diese Routen verwendet. Lege unter Inhaltstypen ein URL-Muster fest oder schalte das Routing für eine Kollektion ohne eigene Seiten ab.", + urlPatternShort: "Sitemap-Links dieser Kollektionen sind möglicherweise falsch.", + + widgetClear: "Alles aktuell", + widgetLastAudit: "Letzte Prüfung {date}.", + widgetPending: "Läuft mit der nächsten geplanten Prüfung.", + widgetYours: { one: "{count} davon ist deiner", other: "{count} davon sind deine" }, + openReport: "Bericht öffnen", + + panelClear: "Hier gibt es nichts zu tun.", + panelSkipped: "Diese Kollektion ist von der Prüfung ausgenommen.", + markReviewed: "Als geprüft markieren", + ignore: "Ignorieren", + undo: "Rückgängig", + reviewedUntil: "Als geprüft markiert; wird ab {date} wieder geprüft.", + ignored: "Ignoriert.", + dismissedHeader: "Zurückgestellt", + + settingsTitle: "Einstellungen zur Aktualität", + settingsIntro: + "Diese Schwellen gelten für alle Kollektionen, außer eine Kollektion legt am Ende des Formulars eigene fest.", + staleMonths: "Monate, bis ein veröffentlichter Eintrag als veraltet gilt", + draftMonths: "Monate, bis ein Entwurf als vergessen gilt", + descriptionMin: "Kürzeste akzeptable SEO-Beschreibung", + descriptionMax: "Längste akzeptable SEO-Beschreibung", + reportMissing: "Einträge ohne SEO-Beschreibung melden", + reportMissingHelp: "Schalte das ab, wenn deine Templates immer eine eigene Beschreibung ausgeben.", + pageSize: "Einträge pro Lauf", + schedule: "Zeitplan (Cron, UTC)", + collectionStale: "{collection}: Monate, bis ein Eintrag veraltet ist (leer: {months}, 0: nie)", + collectionDraft: "{collection}: Monate, bis ein Entwurf vergessen ist (leer: {months}, 0: nie)", + collectionSkip: "{collection}: von der Prüfung ausnehmen", + save: "Speichern", + saved: "Einstellungen gespeichert.", + notSaved: "Einstellungen nicht gespeichert. {reason}", + adminOnly: "Nur Administratoren können diese Einstellungen ändern.", + + hitOverduePublish: "Sollte am {date} live gehen, ist es aber nie.", + hitOverdueUpdate: "Die für {date} geplanten Änderungen wurden nie veröffentlicht.", + hitMissingDescription: "Veröffentlicht ohne SEO-Beschreibung.", + hitMissingFallback: "Keine SEO-Beschreibung; Templates zeigen oft stattdessen das Feld „{field}“.", + hitDescriptionLength: "Die SEO-Beschreibung hat {length} Zeichen; empfohlen sind {min}–{max}.", + hitStale: "Seit {since} nicht bearbeitet.", + hitStaleDraft: "Entwurf seit {since} nicht bearbeitet.", +}; + +const catalogues = { en, de } as const; + +export type Lang = keyof typeof catalogues; +export type Params = Record; + +/** The catalogue for an admin locale: `de`, `de-AT` and `de-CH` read German, everything else English. */ +export function langOf(locale: string | undefined): Lang { + return locale?.toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; +} + +export function t(lang: Lang, key: MessageKey, params: Params = {}): string { + const message: Message = catalogues[lang][key]; + const template = + typeof message === "string" + ? message + : new Intl.PluralRules(lang).select(Number(params.count ?? 0)) === "one" + ? message.one + : message.other; + return template.replace(/\{(\w+)\}/g, (match, name: string) => (name in params ? String(params[name]) : match)); +} diff --git a/packages/content-freshness/src/ids.ts b/packages/content-freshness/src/ids.ts 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..69e5a20 --- /dev/null +++ b/packages/content-freshness/src/panel.ts @@ -0,0 +1,66 @@ +/** + * The entry editor's Freshness panel. + * + * It checks the entry each time it opens, so a finding fixed with a plain + * save, which no content hook reports, leaves the report as soon as someone + * looks at the entry again. It is also where findings are set aside. + */ + +import type { Block, BlockResponse } from "@emdash-cms/blocks"; + +import type { PanelView } from "./check.js"; +import { describeHit, severityText } from "./describe.js"; +import { DISMISSIBLE } from "./dismissals.js"; +import { t, type Lang } from "./i18n.js"; +import { RULES, type Rule } from "./rules.js"; + +export const PANEL_ID = "freshness"; +export const SET_ASIDE_ACTION = "set_aside"; +export const UNDO_ACTION = "undo"; + +/** The rule a panel button carries, if it names one. */ +export function ruleOf(value: unknown): Rule | null { + if (typeof value !== "object" || value === null) return null; + const rule = (value as { rule?: unknown }).rule; + return RULES.find((known) => known === rule) ?? null; +} + +export function renderPanel(lang: Lang, view: PanelView): BlockResponse { + if (view.skipped) return { blocks: [{ type: "context", text: t(lang, "panelSkipped") }] }; + if (view.active.length === 0 && view.setAside.length === 0) { + return { blocks: [{ type: "context", text: t(lang, "panelClear") }] }; + } + + const blocks: Block[] = view.active.map((hit) => { + const kind = DISMISSIBLE[hit.rule]; + return { + type: "section", + text: `${severityText(lang, hit.severity)}: ${describeHit(lang, hit)}`, + ...(kind + ? { + accessory: { + type: "button" as const, + action_id: SET_ASIDE_ACTION, + label: t(lang, kind === "review" ? "markReviewed" : "ignore"), + value: { rule: hit.rule }, + }, + } + : {}), + }; + }); + + if (view.setAside.length > 0) { + blocks.push({ type: "context", text: t(lang, "dismissedHeader") }); + for (const { hit, dismissal } of view.setAside) { + const until = dismissal.until + ? t(lang, "reviewedUntil", { date: dismissal.until.slice(0, 10) }) + : t(lang, "ignored"); + blocks.push({ + type: "section", + text: `${describeHit(lang, hit)} ${until}`, + accessory: { type: "button", action_id: UNDO_ACTION, label: t(lang, "undo"), value: { rule: hit.rule } }, + }); + } + } + return { blocks }; +} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index f9779bd..fe8e00c 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -5,20 +5,24 @@ * `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 { 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, buildReportPage, buildSettingsPage, buildWidget, countFindings, - FIRST_PAGE_ACTION, - PAGE_ACTION, SAVE_SETTINGS_ACTION, + VIEW_ACTION, + viewFrom, } from "./report.js"; +import { renderPanel, ruleOf, SET_ASIDE_ACTION, UNDO_ACTION } from "./panel.js"; import { ensureScheduled } from "./schedule.js"; import { applyForm, readStoredSettings, writeStoredSettings } from "./settings.js"; import { readState } from "./state.js"; @@ -51,18 +55,13 @@ 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 { 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)}`); } @@ -109,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)}`); } @@ -128,36 +127,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, routeCtx.user?.id); + if (interaction.page === "/settings") return buildSettingsPage(settings, lang, await listCollections(ctx)); + 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" }, }; } } @@ -166,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)]); @@ -183,27 +195,32 @@ async function saveSettings( ctx: PluginContext, user: Parameters[0], 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), - toast: { message: "Only an administrator can change these settings.", type: "error" as const }, + ...buildSettingsPage(stored.settings, lang, collections), + toast: { message: t(lang, "adminOnly"), type: "error" as const }, }; } - 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); } catch (error) { return { - ...buildSettingsPage(stored.settings), - toast: { message: `Settings not saved. ${errorMessage(error)}`, type: "error" as const }, + ...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), toast: { message: "Settings 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 8cf8393..f60708e 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -12,29 +12,72 @@ import type { BannerBlock, Block, BlockResponse, + ButtonElement, FormBlock, + FormField, + 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 { ID_BATCH } from "./ids.js"; +import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; -import type { Settings } from "./settings.js"; +import { RANK } from "./rules.js"; +import { collectionField, 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; +} + +export const FIRST_VIEW: ReportView = { collection: null, rank: null, cursor: null }; -const PRIORITY = ["Urgent", "Should fix", "Nice to fix"] as const; +/** 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 +94,48 @@ 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)]); +/** The dashboard card. With `userId`, it also says how many of the entries are that user's. */ +export async function buildWidget( + ctx: PluginContext, + state: State, + lang: Lang, + userId?: string, +): Promise { + const [counts, collections] = await Promise.all([countFindings(ctx), listCollections(ctx)]); + const banner = urlPatternBanner(lang, collections, false); if (counts.total === 0) { if (banner.length > 0) return { blocks: banner }; @@ -97,33 +143,49 @@ export async function buildWidget(ctx: PluginContext, state: State): Promise 0 ? [{ type: "context" as const, text: t(lang, "widgetYours", { count: yours }) }] : []), + { type: "context", text: auditStatus(lang, state) }, + { + type: "actions", + elements: [ + { type: "link", label: t(lang, "openReport"), target: { kind: "plugin-page", path: "/report" } }, + ], + }, + ], }; } /** - * A page of rows, most urgent first, and whether it is a later page. + * A page of rows for `view`, most urgent first, and whether it is a later + * page. * * Storage continues from the cursor row's current rank. When that row has * gone since the previous page, because its entry was fixed or deleted, * nothing follows it and the page comes back empty. That, and a cursor - * storage cannot decode, fall back to the first page. + * storage cannot decode, fall back to the first page of the same filter. */ -async function findingsPage(ctx: PluginContext, cursor: string | undefined) { - const query = { orderBy: { rank: "asc" as const }, limit: PAGE_ROWS }; - if (cursor) { +async function findingsPage(ctx: PluginContext, view: ReportView) { + const where: Record = {}; + if (view.collection) where.collection = view.collection; + if (view.rank !== null) where.rank = view.rank; + const query = { where, orderBy: { rank: "asc" as const }, limit: PAGE_ENTRIES }; + if (view.cursor) { try { - const page = await ctx.storage.findings.query({ ...query, cursor }); + 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 +194,165 @@ 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, requested] = 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" }] : []), - ], + // 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", + 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 }; +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): BlockResponse { +export function buildSettingsPage( + settings: Settings, + lang: Lang, + collections: PluginCollectionInfo[] = [], +): 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 +360,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 +368,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 +376,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 +384,27 @@ 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, + max: ID_BATCH, 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 }, + ...collectionFields(settings, lang, collections), ], - 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/src/rules.ts b/packages/content-freshness/src/rules.ts index 167c413..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" }, }); } @@ -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 0ecaecc..f312779 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -13,26 +13,63 @@ import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import { ID_BATCH } from "./ids.js"; 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. `content.list` returns at most 100. */ + /** 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]> = [ - ["pageSize", 1, 100], + ["pageSize", 1, ID_BATCH], ["staleMonths", 1, 120], ["draftMonths", 1, 120], ["descriptionMin", 0, 300], @@ -75,11 +112,50 @@ 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; } -/** `current` with the submitted form values applied. Unknown keys are ignored. */ -export function applyForm(current: Settings, values: Record): 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 = {}; + 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; +} + +/** + * An override's months, or nothing. A value that is not a number is dropped + * rather than clamped, because 0 would switch the rule off. + */ +function months(value: unknown): number | undefined { + if (!isSet(value)) return undefined; + const n = Number(value); + return Number.isFinite(n) ? clampNumber(n, 0, 120, 0) : undefined; +} + +/** An empty number field: no override. */ +function isSet(value: unknown): boolean { + return value !== undefined && value !== null && !(typeof value === "string" && !value.trim()); +} + +/** + * `current` with the submitted form values applied. Unknown keys are ignored. + * + * `shown` names the collections whose fields the form carried. The admin + * drops an emptied number field from the submission altogether, so for those + * collections a missing month field means the admin cleared it. + */ +export function applyForm(current: Settings, values: Record, shown: string[] = []): Settings { const merged: Record = { ...current }; for (const [key] of NUMBERS) { if (values[key] !== undefined) merged[key] = values[key]; @@ -88,5 +164,23 @@ 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 slug of shown) { + for (const setting of ["staleMonths", "draftMonths"] as const) { + if (!(collectionField(slug, setting) in values)) delete collections[slug]?.[setting]; + } + } + for (const [key, value] of Object.entries(values)) { + const match = COLLECTION_FIELD.exec(key); + if (!match) continue; + const [, slug, setting] = match as unknown as [string, string, keyof CollectionOverride]; + const own = (collections[slug] ??= {}); + if (setting === "skip") own.skip = value === true; + else if (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 10d3dd7..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 @@ -15,8 +18,10 @@ import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import type { EntryDismissals } from "./dismissals.js"; import { evaluateEntries, store } from "./findings.js"; -import { readSettings, type Settings } from "./settings.js"; +import { findingId, ID_BATCH } from "./ids.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"; @@ -29,8 +34,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 @@ -41,7 +44,9 @@ const CLEANUP_BATCH = 100; 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(); @@ -49,8 +54,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" }; } @@ -86,7 +95,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; @@ -106,6 +115,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, { @@ -125,7 +141,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, thresholds, 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 b94c1d9..c035a8e 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"; @@ -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(); @@ -142,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; @@ -163,7 +187,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/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts index a9c38f5..be36909 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() {}, @@ -74,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 new file mode 100644 index 0000000..1db27f2 --- /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" } }; + +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/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"); + }); +}); diff --git a/packages/content-freshness/tests/panel.test.ts b/packages/content-freshness/tests/panel.test.ts new file mode 100644 index 0000000..8d271bb --- /dev/null +++ b/packages/content-freshness/tests/panel.test.ts @@ -0,0 +1,142 @@ +import type { Block, ButtonElement, SectionBlock } from "@emdash-cms/blocks"; +import { validateBlocks } from "@emdash-cms/blocks/server"; +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { PANEL_ID, renderPanel, SET_ASIDE_ACTION, UNDO_ACTION } from "../src/panel.js"; +import { bridgeCalls } from "./bridge-calls.js"; +import { finding, newHost, runSweep, undescribed } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +const sections = (blocks: Block[]) => blocks.filter((block): block is SectionBlock => block.type === "section"); +const button = (section: SectionBlock | undefined) => section?.accessory as ButtonElement | undefined; + +describe("the Freshness panel", () => { + it("shows the entry's findings and records them", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "panel-shows"); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + expect(validateBlocks(response.blocks).valid).toBe(true); + const [section] = sections(response.blocks); + expect(section?.text).toContain("SEO description"); + expect(button(section)).toMatchObject({ action_id: SET_ASIDE_ACTION, label: "Ignore" }); + // Opening the panel is a check: the report has the finding before any sweep. + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); + + it("sets a finding aside so that neither the report nor a sweep brings it back, and undoes it", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "panel-ignores"); + await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + const ignored = await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { + value: { rule: "missing-description" }, + }); + expect(validateBlocks(ignored.blocks).valid).toBe(true); + expect(button(sections(ignored.blocks)[0])).toMatchObject({ action_id: UNDO_ACTION }); + expect(await finding(host, "posts", entry.id)).toBeNull(); + + await runSweep(host); + expect(await finding(host, "posts", entry.id)).toBeNull(); + + await host.admin.actEditorPanel(PANEL_ID, "posts", entry.id, UNDO_ACTION, { value: { rule: "missing-description" } }); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); + + it("opens for an author on their own entry", async () => { + // The report needs plugins:read (editor). The panel is where an author + // sees and sets aside the findings on their own entries. + host = await newHost(); + const author = await host.fixtures.user({ email: "author@example.test", role: "author" }); + const entry = await host.fixtures.content("posts", { + slug: "authors-own", + data: {}, + status: "published", + authorId: author.id, + }); + + const response = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id, { user: author }); + + expect(sections(response.blocks)[0]?.text).toContain("SEO description"); + }); +}); + +describe("the panel on reload", () => { + it("writes nothing when the entry's findings have not changed", async () => { + // EmDash reloads an open panel after every save, autosaves included. + host = await newHost(); + const runtime = host; + const entry = await undescribed(runtime, "posts", "reloaded"); + await runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + const calls = await bridgeCalls(() => runtime.admin.loadEditorPanel(PANEL_ID, "posts", entry.id)); + + expect(calls.filter((call) => /Put|Delete/.test(call))).toEqual([]); + }); + + it("drops a review that has run out", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "expired-review"); + const id = `posts:${entry.id}`; + await host.fixtures.plugin.storage("dismissals", id, { + collection: "posts", + entryId: entry.id, + rules: { stale: { until: "2026-01-01T00:00:00.000Z", by: null, at: "2025-01-01T00:00:00.000Z" } }, + }); + + await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + + expect(await host.inspect.storage.get("dismissals", id)).toBeNull(); + }); +}); + +describe("what is set aside", () => { + async function ignoredEntry(slug: string) { + const runtime = await newHost(); + const entry = await undescribed(runtime, "posts", slug); + await runtime.admin.actEditorPanel(PANEL_ID, "posts", entry.id, SET_ASIDE_ACTION, { + value: { rule: "missing-description" }, + }); + return { runtime, id: `posts:${entry.id}`, entry }; + } + + it("stays while the entry is only in the trash", async () => { + const { runtime, id, entry } = await ignoredEntry("trashed-keeps"); + host = runtime; + await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: false }); + expect(await runtime.inspect.storage.get("dismissals", id)).not.toBeNull(); + }); + + it("goes when the entry is deleted for good", async () => { + const { runtime, id, entry } = await ignoredEntry("deleted-drops"); + host = runtime; + await runtime.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: true }); + expect(await runtime.inspect.storage.get("dismissals", id)).toBeNull(); + }); +}); + +describe("the panel's controls", () => { + it("offer no way to set aside a missed schedule", () => { + const { blocks } = renderPanel("en", { + active: [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish" } }], + setAside: [], + }); + expect(sections(blocks)[0]?.accessory).toBeUndefined(); + }); + + it("offer a review, not an ignore, for a stale entry", () => { + const { blocks } = renderPanel("en", { + active: [{ rule: "stale", severity: "medium", params: { since: "2025-01-01" } }], + setAside: [], + }); + expect(button(sections(blocks)[0])?.label).toBe("Mark as reviewed"); + }); +}); diff --git a/packages/content-freshness/tests/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"]); }); }); diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts index ab5c5f6..5de1434 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,17 +25,26 @@ function row(entryId: string, rank: number): EntryFindings { collection: "posts", entryId, slug: entryId.toLowerCase(), + title: null, locale: "en", + authorId: null, rank, hits: rank === 0 - ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", 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", }; } +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..e1e81c7 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -1,74 +1,137 @@ -import type { Block, FormBlock, TableBlock } from "@emdash-cms/blocks"; +import type { ActionsBlock, Block, ButtonElement, ContextBlock, FormBlock, SectionBlock, SelectElement } from "@emdash-cms/blocks"; +import { validateBlocks } from "@emdash-cms/blocks/server"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; import type { EntryFindings } from "../src/findings.js"; -import { buildReportPage, buildSettingsPage } 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 findings table", () => { - it("tells the translations of one page apart on a multilingual site", async () => { +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"); + }); + + 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("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"]); }); +}); + +describe("filters and paging", () => { + const filtered: ReportView = { collection: "pages", rank: 1, cursor: null }; - 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("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("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"); + 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("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 }); }); }); 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/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index 3f426aa..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"; @@ -33,6 +34,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"); @@ -49,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 f5661e9..9695f90 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", () => { @@ -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); }); }); @@ -31,3 +31,44 @@ 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 }); + // The admin leaves an emptied number field out of the submission. + const next = applyForm(current, { "collection:pages:skip": false }, ["pages"]); + expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); + expect(next.collections).toEqual({}); + }); + + it("keep the overrides of collections the form did not show", () => { + const current = applyForm(DEFAULT_SETTINGS, { "collection:archive:staleMonths": 36 }); + expect(thresholdsFor(applyForm(current, {}, ["pages"]), "archive").staleMonths).toBe(36); + }); + + it("drop an override that is not a number instead of switching the rule off", () => { + const next = normalizeSettings({ collections: { pages: { staleMonths: "abc", draftMonths: Number.NaN } } }); + expect(thresholdsFor(next, "pages").staleMonths).toBe(DEFAULT_SETTINGS.staleMonths); + expect(next.collections).toEqual({}); + }); + + it("leave a collection out of the audit", () => { + const next = applyForm(DEFAULT_SETTINGS, { "collection:legal:skip": true }); + expect(thresholdsFor(next, "legal").skip).toBe(true); + }); + + it("drop what is not a collection slug", () => { + expect(normalizeSettings({ collections: { "Bad Slug": { skip: true }, pages: "nonsense" } }).collections).toEqual({}); + }); +}); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts index da66ba1..04831b2 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 @@ -106,9 +107,52 @@ 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"); }); }); + +describe("a collection left out of the audit", () => { + it("loses its findings with the next sweep, and its entries' panel says why", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "left-out"); + await runSweep(host); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + + const saved = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:skip": true }); + expect(saved.toast?.type).toBe("success"); + await runSweep(host); + + expect(await finding(host, "posts", entry.id)).toBeNull(); + const panel = await host.admin.loadEditorPanel(PANEL_ID, "posts", entry.id); + expect(JSON.stringify(panel.blocks)).toContain("left out of the freshness audit"); + }); + + it("shows the collection's own settings in the form", async () => { + host = await newHost(); + await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:posts:staleMonths": 0 }); + + const page = await host.admin.loadPage("/settings"); + + expect(valid(page)).toBe(true); + const form = page.blocks.find((block) => block.type === "form") as { fields: Array<{ action_id: string; initial_value?: unknown }> }; + expect(form.fields.find((field) => field.action_id === "collection:posts:staleMonths")?.initial_value).toBe(0); + }); +}); + +describe("the dashboard widget", () => { + it("tells an editor how many of the entries that need attention are theirs", async () => { + host = await newHost(); + const editor = await host.fixtures.user({ email: "editor@example.test", role: "editor" }); + await host.fixtures.content("posts", { slug: "editors", data: {}, status: "published", authorId: editor.id }); + await undescribed(host, "posts", "someone-elses"); + await runSweep(host); + + const widget = await host.admin.loadWidget("summary", { user: editor }); + + expect(valid(widget)).toBe(true); + expect(JSON.stringify(widget.blocks)).toContain("1 of them is yours"); + }); +});