diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 2d23738..24f4a0a 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -10,9 +10,25 @@ What it looks for: | Scheduled to publish, or scheduled changes to a published entry, and the date passed without them going live | urgent | | Published without an SEO description | should fix; nice to fix when the entry has an excerpt-like field | | Published and untouched for longer than your threshold | should fix | +| Changes saved to a published entry and not published for longer than your threshold | should fix | +| Published although the date in its expiry field has passed (opt-in per collection) | should fix | | SEO description too short or too long | nice to fix | | Draft abandoned for longer than your threshold | nice to fix | +Unpublished changes are counted in collections that keep revisions, and not +while the changes are scheduled to go live. An entry someone saved recently +is not reported as stale: someone is working on it, and if they stop, the +unpublished changes are reported instead. + +Expiry is opt-in per collection, because a site that keeps past events online +as an archive would otherwise get a finding for each of them. When a +collection has a `datetime` field named like `valid_until`, `end_date`, +`expires_at`, `valid_through` or `deadline`, the report suggests it; choose +it, another field, or "Never expires" in the settings. EmDash stores a date +without a time as midnight in the site's timezone, and the finding names that +instant in UTC. An ignored expiry comes back when the date is moved and +passes again. + Many sites render a description of their own when the SEO panel is empty, usually from an excerpt or intro field. An entry with a non-empty `excerpt`, `description`, `summary`, `intro`, `teaser`, `lead`, `subtitle`, or a field @@ -27,10 +43,12 @@ templates always render a description can switch the check off. 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. + **Mark as reviewed** holds a stale entry, a forgotten draft or forgotten + unpublished changes back until the threshold runs out again, so a page that + is still correct needs no edit to quiet the report. **Ignore** holds a + description finding, or an expiry on a page kept online as an archive, 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 @@ -90,11 +108,12 @@ entry. Without one, the audit is scheduled and never runs. | --- | --- | --- | | Stale after | 12 months | For published entries. | | Draft forgotten after | 6 months | For drafts. | +| Unpublished changes forgotten after | 14 days | For changes saved to a published entry. | | 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 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. | +| Per collection | none | Months for stale entries and forgotten drafts of one collection (empty: the site's value, 0: never), its expiry field, or leave the collection out of the audit. | ## Develop @@ -109,10 +128,10 @@ pnpm build | EmDash | Plugin test suite | | ------ | ----------------- | -| 0.40.1 | 99 passed | -| 0.40.0 | 99 passed | -| 0.39.1 | 99 passed | -| 0.39.0 | 99 passed | +| 0.40.1 | 119 passed | +| 0.40.0 | 119 passed | +| 0.39.1 | 119 passed | +| 0.39.0 | 119 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/src/check.ts b/packages/content-freshness/src/check.ts index 02e8934..38228da 100644 --- a/packages/content-freshness/src/check.ts +++ b/packages/content-freshness/src/check.ts @@ -7,7 +7,7 @@ import type { PluginContext } from "emdash/plugin"; import { dismissalFor, - isSetAside, + hasRunOut, partition, readDismissals, writeDismissals, @@ -16,8 +16,8 @@ import { } 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"; +import { datetimeFields, evaluateEntry, type Hit, type Rule } from "./rules.js"; +import { contextFor, readSettings, thresholdsFor } from "./settings.js"; export interface PanelAction { kind: "set-aside" | "undo"; @@ -34,16 +34,17 @@ export interface PanelView { /** * 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 + * shows. Five `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([ + const [settings, entry, stored, existing, schema] = 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, + ctx.schema ? ctx.schema.getCollection(ref.collection) : Promise.resolve(null), ]); const thresholds = thresholdsFor(settings, ref.collection); if (!entry || thresholds.skip) { @@ -52,11 +53,18 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan } const now = new Date(); + const context = contextFor( + settings, + ref.collection, + schema ? { dateFields: datetimeFields(schema.fields), revisions: schema.supports.includes("revisions") } : undefined, + ); + const hits = evaluateEntry(entry, thresholds, now, context); + 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)) { + for (const [rule, dismissal] of Object.entries(rules) as Array<[Rule, Dismissal]>) { + if (hasRunOut(dismissal, now)) { delete rules[rule]; changed = true; } @@ -65,7 +73,8 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan if (action.kind === "undo") { delete rules[action.rule]; } else { - const dismissal = dismissalFor(action.rule, thresholds, now, action.by); + const hit = hits.find((candidate) => candidate.rule === action.rule); + const dismissal = dismissalFor(action.rule, thresholds, now, action.by, hit); if (dismissal) rules[action.rule] = dismissal; } changed = true; @@ -74,8 +83,9 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan 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); + const evaluated = evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed, context); + await storeOne(ctx, evaluated, existing); + const { active, setAside } = partition(hits, 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 97e7c10..3604cc2 100644 --- a/packages/content-freshness/src/describe.ts +++ b/packages/content-freshness/src/describe.ts @@ -23,6 +23,10 @@ export function describeHit(lang: Lang, hit: Hit): string { return t(lang, "hitStale", params); case "stale-draft": return t(lang, "hitStaleDraft", params); + case "unpublished-changes": + return t(lang, "hitUnpublishedChanges", params); + case "expired": + return t(lang, "hitExpired", params); } } diff --git a/packages/content-freshness/src/dismissals.ts b/packages/content-freshness/src/dismissals.ts index 51bdb22..e357790 100644 --- a/packages/content-freshness/src/dismissals.ts +++ b/packages/content-freshness/src/dismissals.ts @@ -4,14 +4,15 @@ * * "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. + * to make the report quiet. "Ignore" holds a description finding, or an + * expiry on a page kept online as an archive, 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"; +import { DAY_MS, subtractMonths, type Hit, type Rule, type Thresholds } from "./rules.js"; export type DismissKind = "review" | "ignore"; @@ -20,6 +21,8 @@ export const DISMISSIBLE: Partial> = { "stale-draft": "review", "missing-description": "ignore", "description-length": "ignore", + "unpublished-changes": "review", + "expired": "ignore", }; export interface Dismissal { @@ -27,6 +30,12 @@ export interface Dismissal { until: string | null; by: string | null; at: string; + /** + * What exactly was set aside, for a finding that can recur with other + * values: an ignored expiry names its date, so moving the date forward + * and letting it pass again is reported. + */ + subject?: string; } export interface EntryDismissals { @@ -35,25 +44,46 @@ export interface EntryDismissals { 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(); +/** The part of a hit a dismissal must match, for rules whose finding can recur with other values. */ +function subjectOf(hit: Hit): string | undefined { + return hit.rule === "expired" ? String(hit.params.date) : undefined; +} + +export function hasRunOut(dismissal: Dismissal, now: Date): boolean { + return dismissal.until !== null && Date.parse(dismissal.until) <= now.getTime(); +} + +export function isSetAside(dismissals: EntryDismissals | undefined, hit: Hit, now: Date): boolean { + const dismissal = dismissals?.rules[hit.rule]; + if (!dismissal || hasRunOut(dismissal, now)) return false; + return dismissal.subject === undefined || dismissal.subject === subjectOf(hit); } /** `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); + for (const hit of hits) (isSetAside(dismissals, hit, 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 { +/** A dismissal for `rule`, or null when the rule cannot be set aside. `hit` is the finding being set aside. */ +export function dismissalFor( + rule: Rule, + thresholds: Thresholds, + now: Date, + by: string | null, + hit?: Hit, +): Dismissal | null { const kind = DISMISSIBLE[rule]; if (!kind) return null; - if (kind === "ignore") return { until: null, by, at: now.toISOString() }; + if (kind === "ignore") { + const subject = hit ? subjectOf(hit) : undefined; + return { until: null, by, at: now.toISOString(), ...(subject !== undefined ? { subject } : {}) }; + } + if (rule === "unpublished-changes") { + return { until: new Date(now.getTime() + thresholds.pendingDays * DAY_MS).toISOString(), by, at: now.toISOString() }; + } const months = rule === "stale" ? thresholds.staleMonths : thresholds.draftMonths; return { until: subtractMonths(now, -months).toISOString(), by, at: now.toISOString() }; } diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts index cd98a28..a2e6ec2 100644 --- a/packages/content-freshness/src/findings.ts +++ b/packages/content-freshness/src/findings.ts @@ -12,7 +12,7 @@ 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"; +import { evaluateEntry, NO_CONTEXT, worstRank, type EntryContext, type Hit, type Thresholds } from "./rules.js"; export interface EntryFindings { @@ -48,11 +48,12 @@ export function evaluateEntries( now: Date, seenIn: string, dismissals: Map = new Map(), + context: EntryContext = NO_CONTEXT, ): Evaluated { const result: Evaluated = { writes: [], clears: [] }; for (const entry of entries) { const id = findingId(collection, entry.id); - const hits = partition(evaluateEntry(entry, thresholds, now), dismissals.get(id), now).active; + const hits = partition(evaluateEntry(entry, thresholds, now, context), dismissals.get(id), now).active; if (hits.length === 0) { result.clears.push(id); continue; diff --git a/packages/content-freshness/src/i18n.ts b/packages/content-freshness/src/i18n.ts index b42cf77..5e958bf 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -62,6 +62,15 @@ const en = { reportMissingHelp: "Switch off if your templates always render a description of their own.", pageSize: "Entries per run", schedule: "Schedule (cron, UTC)", + pendingDays: "Days before unpublished changes to a published entry count as forgotten", + collectionExpiry: "{collection}: expires after the date in", + expiryUnset: "Not checked", + expirySuggested: "{field} (suggested)", + expiryOff: "Never expires", + expirySuggestTitle: "Expiry dates not checked: {names}", + expirySuggestText: + "These collections have a date field that looks like an expiry date. Choose it in the Freshness settings to report entries still published after it, or choose “Never expires”.", + openSettings: "Open settings", 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", @@ -77,6 +86,8 @@ const en = { hitDescriptionLength: "SEO description is {length} characters; aim for {min}–{max}.", hitStale: "Not touched since {since}.", hitStaleDraft: "Draft untouched since {since}.", + hitUnpublishedChanges: "Changes saved on {since} are not published yet.", + hitExpired: "{field} was {date}, and the entry is still published.", } satisfies Record; export type MessageKey = keyof typeof en; @@ -133,6 +144,15 @@ const de: Record = { reportMissingHelp: "Schalte das ab, wenn deine Templates immer eine eigene Beschreibung ausgeben.", pageSize: "Einträge pro Lauf", schedule: "Zeitplan (Cron, UTC)", + pendingDays: "Tage, bis unveröffentlichte Änderungen an einem veröffentlichten Eintrag als vergessen gelten", + collectionExpiry: "{collection}: läuft ab nach dem Datum in", + expiryUnset: "Nicht geprüft", + expirySuggested: "{field} (vorgeschlagen)", + expiryOff: "Läuft nie ab", + expirySuggestTitle: "Ablaufdaten nicht geprüft: {names}", + expirySuggestText: + "Diese Kollektionen haben ein Datumsfeld, das nach einem Ablaufdatum aussieht. Wähle es in den Einstellungen zur Aktualität, um Einträge zu melden, die danach noch veröffentlicht sind, oder wähle „Läuft nie ab“.", + openSettings: "Einstellungen öffnen", 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", @@ -148,6 +168,8 @@ const de: Record = { hitDescriptionLength: "Die SEO-Beschreibung hat {length} Zeichen; empfohlen sind {min}–{max}.", hitStale: "Seit {since} nicht bearbeitet.", hitStaleDraft: "Entwurf seit {since} nicht bearbeitet.", + hitUnpublishedChanges: "Am {since} gespeicherte Änderungen sind noch nicht veröffentlicht.", + hitExpired: "{field} war am {date}, der Eintrag ist aber noch veröffentlicht.", }; const catalogues = { en, de } as const; diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index fe8e00c..8a032fd 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -24,7 +24,7 @@ import { } 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 { applyForm, readSettings, readStoredSettings, writeStoredSettings } from "./settings.js"; import { readState } from "./state.js"; import { AUDIT_NOW_TASK, runAudit } from "./sweep.js"; @@ -133,7 +133,7 @@ const plugin = { const { settings, state } = await loadAdmin(ctx); 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); + return buildReportPage(ctx, state, settings, lang); } if (interaction.type === "form_submit" && interaction.action_id === SAVE_SETTINGS_ACTION) { @@ -142,13 +142,14 @@ const plugin = { if (interaction.type === "block_action") { if (interaction.action_id === VIEW_ACTION) { - return buildReportPage(ctx, await readState(ctx), lang, viewFrom(interaction.value)); + const [state, settings] = await Promise.all([readState(ctx), readSettings(ctx)]); + return buildReportPage(ctx, state, settings, 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); + const [state, settings] = await Promise.all([readState(ctx), readSettings(ctx)]); return { - ...(await buildReportPage(ctx, state, lang, viewFrom(interaction.value))), + ...(await buildReportPage(ctx, state, settings, lang, viewFrom(interaction.value))), toast: { message: t(lang, state.sweep ? "auditContinues" : "auditStarts"), type: "info" }, }; } diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index f60708e..06c7996 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -26,8 +26,15 @@ import type { EntryFindings } from "./findings.js"; import { ID_BATCH } from "./ids.js"; import { t, type Lang, type MessageKey } from "./i18n.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; -import { RANK } from "./rules.js"; -import { collectionField, type Settings } from "./settings.js"; +import { datetimeFields, detectExpiryField, RANK } from "./rules.js"; +import { + collectionField, + EXPIRY_OFF, + EXPIRY_UNSET, + suggestedExpiry, + thresholdsFor, + type Settings, +} from "./settings.js"; import type { State } from "./state.js"; export const VIEW_ACTION = "report_view"; @@ -251,6 +258,7 @@ function entryBlocks(lang: Lang, row: EntryFindings, collectionLabel: string, wi export async function buildReportPage( ctx: PluginContext, state: State, + settings: Settings, lang: Lang, view: ReportView = FIRST_VIEW, ): Promise { @@ -265,7 +273,7 @@ export async function buildReportPage( 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 banner = [...urlPatternBanner(lang, collections, true), ...expirySuggestion(lang, settings, collections)]; const auditNow: ButtonElement = { type: "button", action_id: AUDIT_NOW_ACTION, @@ -310,6 +318,57 @@ export async function buildReportPage( }; } +/** The choice of expiry field, for a collection that has `datetime` fields at all. */ +function expirySelect(settings: Settings, lang: Lang, item: PluginCollectionInfo, collection: string): FormField[] { + const dateFields = datetimeFields(item.fields); + if (dateFields.length === 0) return []; + const suggested = detectExpiryField(dateFields); + const chosen = settings.collections[item.slug]?.expiryField; + const valid = chosen === EXPIRY_OFF || dateFields.some((field) => field.slug === chosen); + return [ + { + type: "select", + action_id: collectionField(item.slug, "expiryField"), + label: t(lang, "collectionExpiry", { collection }), + options: [ + { label: t(lang, "expiryUnset"), value: EXPIRY_UNSET }, + ...dateFields.map((field) => ({ + label: field.slug === suggested?.slug ? t(lang, "expirySuggested", { field: field.label }) : field.label, + value: field.slug, + })), + { label: t(lang, "expiryOff"), value: EXPIRY_OFF }, + ], + initial_value: valid && chosen ? chosen : EXPIRY_UNSET, + }, + ]; +} + +/** + * A pointer to the settings for collections that have a field named like an + * expiry date but no decision about it yet. Expiry is opt-in, so without this + * nobody would learn the rule exists. + */ +function expirySuggestion(lang: Lang, settings: Settings, collections: PluginCollectionInfo[]): Block[] { + const suggestions = collections.flatMap((item) => { + if (thresholdsFor(settings, item.slug).skip) return []; + const field = suggestedExpiry(settings, item.slug, datetimeFields(item.fields)); + return field ? [`${item.label || item.slug} (${field.label})`] : []; + }); + if (suggestions.length === 0) return []; + return [ + { + type: "banner", + variant: "default", + title: t(lang, "expirySuggestTitle", { names: suggestions.join(", ") }), + description: t(lang, "expirySuggestText"), + }, + { + type: "actions", + elements: [{ type: "link", label: t(lang, "openSettings"), target: { kind: "plugin-page", path: "/settings" } }], + }, + ]; +} + function collectionFields(settings: Settings, lang: Lang, collections: PluginCollectionInfo[]): FormField[] { return collections.flatMap((item): FormField[] => { const own = settings.collections[item.slug] ?? {}; @@ -331,6 +390,7 @@ function collectionFields(settings: Settings, lang: Lang, collections: PluginCol max: 120, ...(own.draftMonths !== undefined ? { initial_value: own.draftMonths } : {}), }, + ...expirySelect(settings, lang, item, collection), { type: "toggle", action_id: collectionField(item.slug, "skip"), @@ -381,6 +441,14 @@ export function buildSettingsPage( max: 300, initial_value: settings.descriptionMax, }, + { + type: "number_input", + action_id: "pendingDays", + label: t(lang, "pendingDays"), + min: 1, + max: 365, + initial_value: settings.pendingDays, + }, { type: "toggle", action_id: "reportMissingDescriptions", diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index e319bfa..490a2e4 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -1,9 +1,11 @@ /** * The freshness rules — pure functions over one entry. * - * Every rule reads fields the content API already returns - * (`updatedAt`, `status`, `scheduledAt`, `seo`), so the whole audit runs - * without network access and without knowing the collection's schema. + * Every rule reads fields the content API already returns (`updatedAt`, + * `status`, `scheduledAt`, `seo`, the revision ids), plus two facts about the + * entry's collection from `schema:read`: whether it keeps revisions, and which + * `datetime` field, if any, the settings chose as its expiry date. The whole + * audit runs without network access. * * A rule yields a `Hit`: the rule, its severity and the values its sentence * needs. Sentences are built when a page renders, never stored, so a stored @@ -20,6 +22,8 @@ export const RULES = [ "stale", "stale-draft", "description-length", + "unpublished-changes", + "expired", ] as const; export type Rule = (typeof RULES)[number]; @@ -44,6 +48,8 @@ export interface Thresholds { descriptionMax: number; /** Off for a site whose templates always render a description of their own. */ reportMissingDescriptions: boolean; + /** Changes saved to a published entry and left unpublished for longer than this are reported. */ + pendingDays: number; } export const DEFAULT_THRESHOLDS: Thresholds = { @@ -52,8 +58,27 @@ export const DEFAULT_THRESHOLDS: Thresholds = { descriptionMin: 50, descriptionMax: 160, reportMissingDescriptions: true, + pendingDays: 14, }; +/** The `datetime` field after which a collection's entries have expired. */ +export interface ExpiryField { + slug: string; + label: string; +} + +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** What a rule needs to know about an entry's collection. */ +export interface EntryContext { + /** The field the entry expires after, when the settings chose one. */ + expiry: ExpiryField | null; + /** Whether the collection keeps revisions, so a draft revision means unpublished changes. */ + revisions: boolean; +} + +export const NO_CONTEXT: EntryContext = { expiry: null, revisions: false }; + /** * How long a passed schedule may wait before it counts as missed. EmDash * publishes scheduled entries from its own cron run, which on most sites @@ -75,10 +100,48 @@ function fallbackField(data: Record): string | null { return null; } -export function evaluateEntry(entry: PluginContentItem, thresholds: Thresholds, now: Date): Hit[] { +export function evaluateEntry( + entry: PluginContentItem, + thresholds: Thresholds, + now: Date, + context: EntryContext = NO_CONTEXT, +): Hit[] { const hits: Hit[] = []; const published = entry.status === "published"; - const updatedAt = parseDate(entry.updatedAt); + + // In a collection with revisions, a save stages the changes in a draft + // revision and leaves `updatedAt` alone; publishing or discarding them + // clears it. Revision ids are ULIDs, so the draft revision's id is where + // the time of the last save is. Without revisions the pointer means + // nothing: a restore can set it and no save clears it. + const draftSavedAt = + context.revisions && entry.draftRevisionId && entry.draftRevisionId !== entry.liveRevisionId + ? ulidTime(entry.draftRevisionId) + : null; + const lastEdit = latest(parseDate(entry.updatedAt), draftSavedAt); + + // Changes scheduled to go live are waiting on purpose; a missed schedule + // is `overdue-schedule`'s to report. + if ( + published && + draftSavedAt && + !entry.scheduledAt && + draftSavedAt.getTime() < now.getTime() - thresholds.pendingDays * DAY_MS + ) { + hits.push({ rule: "unpublished-changes", severity: "medium", params: { since: day(draftSavedAt) } }); + } + + // EmDash stores every `datetime` value as a UTC instant, reading a value + // without a time as midnight in the site's timezone. A plugin does not + // know that timezone, so the instant is compared as it is and named with + // its time in UTC. + if (published && context.expiry) { + const value = entry.data[context.expiry.slug]; + const ends = typeof value === "string" ? parseDate(value) : null; + if (ends && ends.getTime() <= now.getTime()) { + hits.push({ rule: "expired", severity: "medium", params: { field: context.expiry.label, date: minuteUtc(ends) } }); + } + } // A schedule that came and went. On an unpublished entry it was meant to // publish it; on a published one, to publish its pending changes. Either @@ -123,13 +186,15 @@ 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 && thresholds.staleMonths > 0 && updatedAt < subtractMonths(now, thresholds.staleMonths)) { - hits.push({ rule: "stale", severity: "medium", params: { since: day(updatedAt) } }); + // not expected to change, such as testimonials. The last edit counts + // saves staged in a draft revision, so an entry someone is working on is + // not stale; if they stop, `unpublished-changes` says so. + if (lastEdit) { + if (published && thresholds.staleMonths > 0 && lastEdit < subtractMonths(now, thresholds.staleMonths)) { + hits.push({ rule: "stale", severity: "medium", params: { since: day(lastEdit) } }); } - if (entry.status === "draft" && thresholds.draftMonths > 0 && updatedAt < subtractMonths(now, thresholds.draftMonths)) { - hits.push({ rule: "stale-draft", severity: "low", params: { since: day(updatedAt) } }); + if (entry.status === "draft" && thresholds.draftMonths > 0 && lastEdit < subtractMonths(now, thresholds.draftMonths)) { + hits.push({ rule: "stale-draft", severity: "low", params: { since: day(lastEdit) } }); } } @@ -157,6 +222,43 @@ export function subtractMonths(from: Date, months: number): Date { return shifted; } +const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** The time a ULID was minted, from its first ten characters; null for anything that is not a ULID. */ +export function ulidTime(id: string | null | undefined): Date | null { + if (!id || !/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(id)) return null; + let ms = 0; + for (const char of id.slice(0, 10).toUpperCase()) ms = ms * 32 + CROCKFORD.indexOf(char); + return new Date(ms); +} + +/** Field names that say when an entry stops being current. */ +const EXPIRY_NAME = + /(?:^|_)(?:end|ends|until|expires?|expiry|expiration|deadline|closes?|closing|valid_to|valid_through)(?:_at|_date|_on|_time|_datetime)?$/; + +/** A collection's `datetime` fields, the only fields an expiry date can be in. */ +export function datetimeFields(fields: Array<{ slug: string; label: string; type: string }>): ExpiryField[] { + return fields + .filter((field) => field.type === "datetime") + .map((field) => ({ slug: field.slug, label: field.label || field.slug })); +} + +/** The first of `fields` whose name says when an entry runs out: a suggestion, never applied on its own. */ +export function detectExpiryField(fields: ExpiryField[]): ExpiryField | null { + return fields.find((field) => EXPIRY_NAME.test(field.slug)) ?? null; +} + +function latest(a: Date | null, b: Date | null): Date | null { + if (!a) return b; + if (!b) return a; + return a > b ? a : b; +} + +/** An instant to the minute, in UTC: `2026-01-30 23:00 UTC`. */ +function minuteUtc(date: Date): string { + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + /** Tolerant date parse — a malformed timestamp is skipped, not thrown on. */ export function parseDate(value: string | null | undefined): Date | null { if (!value) return null; diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index f312779..301c393 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -14,7 +14,7 @@ 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"; +import { DEFAULT_THRESHOLDS, detectExpiryField, type EntryContext, type ExpiryField, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; @@ -25,6 +25,11 @@ export const SETTINGS_KEY = "settings"; export interface CollectionOverride { staleMonths?: number; draftMonths?: number; + /** + * The `datetime` field entries expire after, or `"off"` for never. Unset, + * no expiry is checked and the report suggests a field whose name fits. + */ + expiryField?: string; /** Leave the collection out of the audit altogether. */ skip?: boolean; } @@ -47,7 +52,12 @@ export const DEFAULT_SETTINGS: Settings = { 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)$/; +const COLLECTION_FIELD = /^collection:([a-z][a-z0-9_]*):(staleMonths|draftMonths|expiryField|skip)$/; + +/** The expiry select's value for "not chosen yet": nothing checked, a field suggested. */ +export const EXPIRY_UNSET = "unset"; +/** The expiry select's value for "entries of this collection never expire": nothing checked or suggested. */ +export const EXPIRY_OFF = "off"; export function collectionField(slug: string, setting: keyof CollectionOverride): string { return `collection:${slug}:${setting}`; @@ -62,11 +72,42 @@ export function thresholdsFor(settings: Settings, collection: string): Threshold descriptionMin: settings.descriptionMin, descriptionMax: settings.descriptionMax, reportMissingDescriptions: settings.reportMissingDescriptions, + pendingDays: settings.pendingDays, skip: own.skip === true, }; } -type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax"; +/** + * The field `collection`'s entries expire after, when the settings chose one + * the collection still has. Expiry is opt-in: a site that keeps past events + * online as an archive would otherwise get a finding for every one of them + * the moment the plugin is installed. `fields` are the collection's + * `datetime` fields. + */ +export function expiryFor(settings: Settings, collection: string, fields: ExpiryField[]): ExpiryField | null { + const chosen = settings.collections[collection]?.expiryField; + return fields.find((field) => field.slug === chosen) ?? null; +} + +/** What the rules need to know about `collection`, from what a sweep or a check read about it. */ +export function contextFor( + settings: Settings, + collection: string, + info: { dateFields: ExpiryField[]; revisions: boolean } | undefined, +): EntryContext { + return { + expiry: expiryFor(settings, collection, info?.dateFields ?? []), + revisions: info?.revisions ?? false, + }; +} + +/** A field to suggest for a collection whose expiry nobody has decided on yet. */ +export function suggestedExpiry(settings: Settings, collection: string, fields: ExpiryField[]): ExpiryField | null { + if (settings.collections[collection]?.expiryField !== undefined) return null; + return detectExpiryField(fields); +} + +type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax" | "pendingDays"; const NUMBERS: Array<[NumberKey, number, number]> = [ ["pageSize", 1, ID_BATCH], @@ -74,6 +115,7 @@ const NUMBERS: Array<[NumberKey, number, number]> = [ ["draftMonths", 1, 120], ["descriptionMin", 0, 300], ["descriptionMax", 0, 300], + ["pendingDays", 1, 365], ]; export interface StoredSettings { @@ -121,13 +163,16 @@ function normalizeOverrides(source: unknown): Record 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 { staleMonths, draftMonths, expiryField, 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 (typeof expiryField === "string" && (expiryField === EXPIRY_OFF || SLUG.test(expiryField))) { + own.expiryField = expiryField; + } if (Object.keys(own).length > 0) overrides[slug] = own; } return overrides; @@ -178,7 +223,10 @@ export function applyForm(current: Settings, values: Record, sh 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 if (setting === "expiryField") { + if (typeof value === "string" && value !== EXPIRY_UNSET) own.expiryField = value; + else delete own.expiryField; + } else if (isSet(value)) own[setting] = value; else delete own[setting]; } merged.collections = collections; diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts index 47ae1d3..15be4b9 100644 --- a/packages/content-freshness/src/state.ts +++ b/packages/content-freshness/src/state.ts @@ -7,7 +7,19 @@ import type { PluginContext } from "emdash/plugin"; export const STATE_KEY = "state"; +/** Raised whenever `Sweep` changes shape; a sweep of an older shape starts over. */ +export const SWEEP_VERSION = 2; + +/** What a sweep reads about a collection when it starts. */ +export interface CollectionInfo { + /** Its `datetime` fields, for the expiry rule. */ + dateFields: Array<{ slug: string; label: string }>; + /** Whether it keeps revisions, for the unpublished-changes rule. */ + revisions: boolean; +} + export interface Sweep { + version: number; /** Rows written by this sweep carry this as `seenIn`; older ones are removed at the end. */ startedAt: string; /** The collections as they were when the sweep started, walked in order. */ @@ -16,6 +28,8 @@ export interface Sweep { /** `content.list` cursor within `collections[index]`. */ cursor: string | null; phase: "audit" | "cleanup"; + /** Per collection, read with the collection list so a page needs no schema call. */ + info: Record; } export interface State { diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index 5c01f22..ba72794 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -21,9 +21,10 @@ import type { PluginContext } from "emdash/plugin"; import type { EntryDismissals } from "./dismissals.js"; import { evaluateEntries, store } from "./findings.js"; import { findingId, ID_BATCH } from "./ids.js"; -import { readSettings, thresholdsFor, type Settings } from "./settings.js"; +import { datetimeFields } from "./rules.js"; +import { contextFor, readSettings, thresholdsFor, type Settings } from "./settings.js"; import { AUDIT_TASK } from "./schedule.js"; -import { readState, writeState, type State, type Sweep } from "./state.js"; +import { readState, SWEEP_VERSION, writeState, type CollectionInfo, type State, type Sweep } from "./state.js"; /** The one-shot the "Audit now" button schedules. See `plugin.ts` for why it has its own name. */ export const AUDIT_NOW_TASK = "audit-now"; @@ -51,7 +52,11 @@ export async function runAudit(ctx: PluginContext, event: ChainEvent): 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" }; + const audited = (await listCollections(ctx)).filter((collection) => !thresholdsFor(settings, collection.slug).skip); + const info: Record = {}; + for (const collection of audited) { + info[collection.slug] = { + dateFields: datetimeFields(collection.fields), + revisions: collection.supports.includes("revisions"), + }; + } + return { + version: SWEEP_VERSION, + startedAt: now.toISOString(), + collections: audited.map((collection) => collection.slug), + index: 0, + cursor: null, + phase: "audit", + info, + }; } function abandoned(sweep: Sweep, now: Date): boolean { @@ -145,7 +163,15 @@ async function auditPage( 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), + evaluateEntries( + collection, + page.items, + thresholds, + now, + sweep.startedAt, + dismissals as Map, + contextFor(settings, collection, sweep.info[collection]), + ), ); if (page.cursor) { diff --git a/packages/content-freshness/tests/audit.test.ts b/packages/content-freshness/tests/audit.test.ts index d12ad9d..58f41da 100644 --- a/packages/content-freshness/tests/audit.test.ts +++ b/packages/content-freshness/tests/audit.test.ts @@ -1,7 +1,7 @@ import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { STATE_KEY } from "../src/state.js"; +import { STATE_KEY, SWEEP_VERSION, type State } from "../src/state.js"; import { failNextCall } from "./bridge-calls.js"; import { finding, newHost, runSweep, undescribed } from "./host.js"; @@ -96,7 +96,15 @@ describe("a sweep that cannot list a collection", () => { entryUpdatedAt: "2026-01-01T00:00:00.000Z", seenIn: "2026-01-01T00:00:00.000Z", }); - const sweep = { startedAt: new Date().toISOString(), collections: ["gone", "posts"], index: 0, cursor: null, phase: "audit" }; + const sweep = { + version: SWEEP_VERSION, + startedAt: new Date().toISOString(), + collections: ["gone", "posts"], + index: 0, + cursor: null, + phase: "audit", + info: {}, + }; await host.fixtures.plugin.kv(STATE_KEY, { sweep, lastFinishedAt: null }); host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); @@ -108,6 +116,23 @@ describe("a sweep that cannot list a collection", () => { }); }); +describe("a sweep started by an earlier version of the plugin", () => { + it("starts over instead of auditing with information it lacks", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "after-upgrade"); + const old = { startedAt: new Date().toISOString(), collections: ["posts"], index: 0, cursor: null, phase: "audit" }; + await host.fixtures.plugin.kv(STATE_KEY, { sweep: old, lastFinishedAt: null }); + + host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); + await host.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } }); + const state = await host.inspect.kv.get(STATE_KEY); + + expect(state?.sweep?.version ?? SWEEP_VERSION).toBe(SWEEP_VERSION); + while ((await host.scheduled.run()).processed > 0); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); +}); + describe("content hooks", () => { it("drop an entry's row when it is deleted", async () => { host = await newHost(); diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts index c035a8e..720f5cd 100644 --- a/packages/content-freshness/tests/budget.test.ts +++ b/packages/content-freshness/tests/budget.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; 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 { STATE_KEY, SWEEP_VERSION, type State } from "../src/state.js"; import { bridgeCalls } from "./bridge-calls.js"; import { finding, newHost, undescribed } from "./host.js"; @@ -98,11 +98,13 @@ describe("cron runs", () => { await runtime.fixtures.plugin.storage("findings", `posts:OLD${i}`, staleRow(`OLD${i}`)); } const sweep = { + version: SWEEP_VERSION, startedAt: new Date().toISOString(), collections: ["posts"], index: 1, cursor: null, phase: "cleanup", + info: {}, }; await runtime.fixtures.plugin.kv(STATE_KEY, { sweep, lastFinishedAt: null, scheduledAs: null }); diff --git a/packages/content-freshness/tests/expiry.test.ts b/packages/content-freshness/tests/expiry.test.ts new file mode 100644 index 0000000..a7bfcb5 --- /dev/null +++ b/packages/content-freshness/tests/expiry.test.ts @@ -0,0 +1,89 @@ +import { validateBlocks } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { PANEL_ID, SET_ASIDE_ACTION } from "../src/panel.js"; +import { SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { finding, runSweep } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +/** A collection of offers with a `valid_until` date, and one offer that ran out. */ +async function offers() { + const runtime = await createPluginRuntimeTestHost(); + await runtime.fixtures.collection({ + slug: "offers", + label: "Offers", + routable: true, + urlPattern: "/offers/{slug}", + fields: [{ slug: "valid_until", label: "Valid until", type: "datetime" }], + }); + // How EmDash stores 31 January for a site in Berlin. + const entry = await runtime.fixtures.content("offers", { + slug: "summer-sale", + data: { valid_until: "2026-01-30T23:00:00.000Z" }, + status: "published", + }); + return { runtime, entry }; +} + +const chooseField = (runtime: PluginRuntimeTestHost, value: string) => + runtime.admin.submit("/settings", SAVE_SETTINGS_ACTION, { "collection:offers:expiryField": value }); + +describe("expiry", () => { + it("is suggested in the report, and checks nothing until the field is chosen", async () => { + const { runtime, entry } = await offers(); + host = runtime; + await runSweep(runtime); + + expect(await finding(runtime, "offers", entry.id)).toBeNull(); + const report = await runtime.admin.loadPage("/report"); + expect(validateBlocks(report.blocks).valid).toBe(true); + expect(JSON.stringify(report.blocks)).toContain("Expiry dates not checked: Offers (Valid until)"); + const settings = await runtime.admin.loadPage("/settings"); + expect(validateBlocks(settings.blocks).valid).toBe(true); + expect(JSON.stringify(settings.blocks)).toContain("Valid until (suggested)"); + }); + + it("reports an expired entry once the field is chosen, naming the instant in UTC", async () => { + const { runtime, entry } = await offers(); + host = runtime; + await chooseField(runtime, "valid_until"); + await runSweep(runtime); + + const row = await finding(runtime, "offers", entry.id); + expect(row?.hits).toContainEqual( + expect.objectContaining({ rule: "expired", params: { field: "Valid until", date: "2026-01-30 23:00 UTC" } }), + ); + expect(JSON.stringify((await runtime.admin.loadPage("/report")).blocks)).not.toContain("Expiry dates not checked"); + }); + + it("comes back after an ignore once the date moved and passed again", async () => { + const { runtime, entry } = await offers(); + host = runtime; + await chooseField(runtime, "valid_until"); + await runtime.admin.actEditorPanel(PANEL_ID, "offers", entry.id, SET_ASIDE_ACTION, { value: { rule: "expired" } }); + expect(await finding(runtime, "offers", entry.id)).toBeNull(); + + // Next season's offer, which has run out as well. The collection keeps + // revisions, so the new date is live once it is published. + await runtime.actions.content.update("offers", entry.id, { data: { valid_until: "2026-08-31T22:00:00.000Z" } }); + await runtime.actions.content.publish("offers", entry.id); + const panel = await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); + + expect(JSON.stringify(panel.blocks)).toContain("2026-08-31 22:00 UTC"); + expect(await finding(runtime, "offers", entry.id)).not.toBeNull(); + }); + + it("is neither checked nor suggested once a collection never expires", async () => { + const { runtime } = await offers(); + host = runtime; + await chooseField(runtime, "off"); + expect(JSON.stringify((await runtime.admin.loadPage("/report")).blocks)).not.toContain("Expiry dates not checked"); + }); +}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index e1e81c7..9f3da38 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -30,8 +30,8 @@ function fakeContext(rows: EntryFindings[], more = false) { return { schema: { listCollections: async () => [ - { slug: "pages", label: "Pages", routable: true, urlPattern: "/{slug}" }, - { slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}" }, + { slug: "pages", label: "Pages", routable: true, urlPattern: "/{slug}", fields: [], supports: [] }, + { slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}", fields: [], supports: [] }, ], }, storage: { @@ -62,7 +62,7 @@ const buttons = (blocks: Block[]) => 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 { blocks } = await buildReportPage(fakeContext([row("about", "de", { title: "Über uns" })]), STATE, DEFAULT_SETTINGS, "en"); const [section] = sections(blocks); expect(section?.text).toContain("Über uns"); expect(section?.accessory).toMatchObject({ @@ -74,7 +74,7 @@ describe("the report's entries", () => { 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"); + const { blocks } = await buildReportPage(fakeContext([entry]), STATE, DEFAULT_SETTINGS, "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"); @@ -83,12 +83,12 @@ describe("the report's entries", () => { 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, "en"); + const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE, DEFAULT_SETTINGS, "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"); + const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE, DEFAULT_SETTINGS, "en"); expect(sections(blocks).map((section) => section.text)).toEqual(["about · Pages", "contact · Pages"]); }); }); @@ -97,7 +97,7 @@ describe("filters and paging", () => { const filtered: ReportView = { collection: "pages", rank: 1, cursor: null }; it("keep the other filter when one changes", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, "en", filtered); + const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, DEFAULT_SETTINGS, "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 }); @@ -108,7 +108,7 @@ describe("filters and paging", () => { }); it("page within the filters", async () => { - const { blocks } = await buildReportPage(fakeContext([row("about", "en")], true), STATE, "en", filtered); + const { blocks } = await buildReportPage(fakeContext([row("about", "en")], true), STATE, DEFAULT_SETTINGS, "en", filtered); const next = buttons(blocks).find((button) => button.action_id === VIEW_ACTION); expect(viewFrom(next?.value)).toEqual({ ...filtered, cursor: "next-page" }); }); @@ -116,7 +116,7 @@ describe("filters and paging", () => { 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); + const { blocks } = await buildReportPage(fakeContext([row("about", "en")]), STATE, DEFAULT_SETTINGS, "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); diff --git a/packages/content-freshness/tests/routing.test.ts b/packages/content-freshness/tests/routing.test.ts index 52ad84b..2a19b2c 100644 --- a/packages/content-freshness/tests/routing.test.ts +++ b/packages/content-freshness/tests/routing.test.ts @@ -3,6 +3,7 @@ import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; import { buildReportPage, buildWidget } from "../src/report.js"; +import { DEFAULT_SETTINGS } from "../src/settings.js"; import type { State } from "../src/state.js"; /** @@ -18,7 +19,7 @@ const STATE: State = { sweep: null, lastFinishedAt: null }; function fakeContext(collections: Collection[]) { return { - schema: { listCollections: async () => collections }, + schema: { listCollections: async () => collections.map((item) => ({ fields: [], supports: [], ...item })) }, storage: { findings: { count: async () => 0, @@ -40,7 +41,7 @@ describe("the URL pattern warning", () => { { slug: "authors", label: "Authors", routable: false, urlPattern: null }, ]); - const { blocks } = await buildReportPage(ctx, STATE, "en"); + const { blocks } = await buildReportPage(ctx, STATE, DEFAULT_SETTINGS, "en"); const [banner] = banners(blocks); expect(banner?.title).toContain("Posts"); expect(banner?.title).not.toContain("Pages"); @@ -55,7 +56,7 @@ describe("the URL pattern warning", () => { 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, "en")).blocks)).toHaveLength(0); + expect(banners((await buildReportPage(ctx, STATE, DEFAULT_SETTINGS, "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 5edfff8..2ddb0a2 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import { describeHit } from "../src/describe.js"; -import { DEFAULT_THRESHOLDS, evaluateEntry, parseDate, subtractMonths } from "../src/rules.js"; +import { + DEFAULT_THRESHOLDS, + detectExpiryField, + evaluateEntry, + parseDate, + subtractMonths, + ulidTime, +} from "../src/rules.js"; import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; const NOW = new Date("2026-09-08T00:00:00.000Z"); @@ -171,3 +178,137 @@ describe("parseDate", () => { expect(parseDate("2026-01-01T00:00:00.000Z")).toBeInstanceOf(Date); }); }); + +/** A ULID minted at `at`: the time in its first ten characters, anything after. */ +function ulidAt(at: string): string { + const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let ms = Date.parse(at); + let time = ""; + for (let i = 0; i < 10; i++) { + time = alphabet[ms % 32] + time; + ms = Math.floor(ms / 32); + } + return `${time}0000000000000000`; +} + +describe("ulidTime", () => { + it("reads the time a ULID was minted", () => { + // The example from the ULID specification. + expect(ulidTime("01ARYZ6S41TSV4RRFFQ69G5FAV")?.getTime()).toBe(1469918176385); + expect(ulidTime(ulidAt("2026-08-01T10:00:00.000Z"))?.toISOString()).toBe("2026-08-01T10:00:00.000Z"); + }); + + it("returns null for anything that is not a ULID", () => { + expect(ulidTime("rev-1")).toBeNull(); + expect(ulidTime(null)).toBeNull(); + }); +}); + +const REVISIONS = { expiry: null, revisions: true }; + +describe("unpublished changes", () => { + const pending = (savedAt: string, extra = {}) => + entry({ draftRevisionId: ulidAt(savedAt), liveRevisionId: ulidAt("2026-01-01T00:00:00.000Z"), ...extra }); + const found = (item: PluginContentItem, context = REVISIONS) => + evaluateEntry(item, DEFAULT_THRESHOLDS, NOW, context).map((hit) => hit.rule); + + it("are reported once they have waited longer than the threshold", () => { + const hits = evaluateEntry(pending("2026-08-01T00:00:00.000Z"), DEFAULT_THRESHOLDS, NOW, REVISIONS); + expect(hits).toContainEqual({ rule: "unpublished-changes", severity: "medium", params: { since: "2026-08-01" } }); + }); + + it("are not reported while they are recent, or when the draft is the live revision", () => { + expect(found(pending("2026-09-01T00:00:00.000Z"))).not.toContain("unpublished-changes"); + const live = ulidAt("2026-06-01T00:00:00.000Z"); + expect(found(entry({ draftRevisionId: live, liveRevisionId: live }))).not.toContain("unpublished-changes"); + expect(found(pending("2026-06-01T00:00:00.000Z", { status: "draft" }))).not.toContain("unpublished-changes"); + }); + + it("are not reported when they are scheduled to go live", () => { + // Waiting on purpose; a missed schedule is overdue-schedule's finding. + const scheduled = pending("2026-06-01T00:00:00.000Z", { scheduledAt: "2026-10-01T00:00:00.000Z" }); + expect(found(scheduled)).not.toContain("unpublished-changes"); + }); + + it("mean nothing in a collection without revisions", () => { + // A restore there can leave a draft pointer that no save clears. + expect(found(pending("2026-06-01T00:00:00.000Z"), { expiry: null, revisions: false })).not.toContain( + "unpublished-changes", + ); + }); + + it("keep an entry someone is working on from counting as stale", () => { + const old = { updatedAt: "2024-01-01T00:00:00.000Z" }; + expect(found(pending("2026-09-01T00:00:00.000Z", old))).not.toContain("stale"); + // Changes older than the stale threshold do not. + expect(found(pending("2025-01-01T00:00:00.000Z", old))).toContain("stale"); + }); + + it("date a draft by its last save, not by the row's updatedAt", () => { + // A save to a draft in a collection with revisions leaves updatedAt alone. + const draft = entry({ + status: "draft", + updatedAt: "2025-01-01T00:00:00.000Z", + draftRevisionId: ulidAt("2026-09-01T00:00:00.000Z"), + liveRevisionId: null, + }); + expect(found(draft)).not.toContain("stale-draft"); + expect(found(draft, { expiry: null, revisions: false })).toContain("stale-draft"); + }); +}); + +describe("expired entries", () => { + const context = { expiry: { slug: "valid_until", label: "Valid until" }, revisions: false }; + const until = (value: unknown, extra = {}) => + evaluateEntry(entry({ data: { valid_until: value }, ...extra }), DEFAULT_THRESHOLDS, NOW, context).map( + (hit) => hit.rule, + ); + + it("are reported once the instant in their expiry field has passed, named in UTC", () => { + // EmDash stores a date without a time as midnight in the site's + // timezone: 31 January in Berlin is 23:00 UTC the day before. + const [hit] = evaluateEntry(entry({ data: { valid_until: "2026-01-30T23:00:00.000Z" } }), DEFAULT_THRESHOLDS, NOW, context); + expect(hit).toEqual({ + rule: "expired", + severity: "medium", + params: { field: "Valid until", date: "2026-01-30 23:00 UTC" }, + }); + }); + + it("are not reported before the instant, while unpublished, or without a chosen field", () => { + expect(until("2026-09-08T00:00:01.000Z")).not.toContain("expired"); + expect(until("2026-09-01T00:00:00.000Z", { status: "draft" })).not.toContain("expired"); + expect(until(42)).not.toContain("expired"); + expect(rules(entry({ data: { valid_until: "2026-09-01T00:00:00.000Z" } }))).not.toContain("expired"); + }); +}); + +describe("detectExpiryField", () => { + const fields = (...slugs: string[]) => slugs.map((slug) => ({ slug, label: slug })); + + it("picks the first field whose name says when an entry runs out", () => { + expect(detectExpiryField(fields("start_date", "valid_until", "end_date"))?.slug).toBe("valid_until"); + }); + + it("recognises the usual names and nothing else", () => { + for (const slug of [ + "end_date", + "ends_on", + "event_end", + "end_datetime", + "expires_at", + "expiry", + "expiration_date", + "deadline", + "closing_date", + "offer_until", + "valid_to", + "valid_through", + ]) { + expect(detectExpiryField(fields(slug))?.slug).toBe(slug); + } + for (const slug of ["start_date", "published_on", "weekend", "friend_date"]) { + expect(detectExpiryField(fields(slug))).toBeNull(); + } + }); +}); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 9695f90..6e97071 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { applyForm, DEFAULT_SETTINGS, normalizeSettings, thresholdsFor } from "../src/settings.js"; +import { + applyForm, + DEFAULT_SETTINGS, + expiryFor, + normalizeSettings, + suggestedExpiry, + thresholdsFor, +} from "../src/settings.js"; describe("normalizeSettings", () => { it("returns defaults for nothing stored", () => { @@ -72,3 +79,32 @@ describe("settings per collection", () => { expect(normalizeSettings({ collections: { "Bad Slug": { skip: true }, pages: "nonsense" } }).collections).toEqual({}); }); }); + +describe("the expiry field of a collection", () => { + const dates = [ + { slug: "starts_on", label: "Starts" }, + { slug: "valid_until", label: "Valid until" }, + ]; + + it("is checked only once the settings choose it", () => { + // Opt-in: a site that keeps past events online would otherwise get a + // finding for each of them on install. + expect(expiryFor(DEFAULT_SETTINGS, "offers", dates)).toBeNull(); + const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "valid_until" }); + expect(expiryFor(chosen, "offers", dates)).toEqual({ slug: "valid_until", label: "Valid until" }); + }); + + it("is suggested until someone decides, and no longer once they chose never", () => { + expect(suggestedExpiry(DEFAULT_SETTINGS, "offers", dates)?.slug).toBe("valid_until"); + const off = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "off" }); + expect(suggestedExpiry(off, "offers", dates)).toBeNull(); + expect(expiryFor(off, "offers", dates)).toBeNull(); + }); + + it("goes back to undecided for unset, and checks nothing for a field the collection no longer has", () => { + const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "starts_on" }); + expect(applyForm(chosen, { "collection:offers:expiryField": "unset" }).collections).toEqual({}); + const gone = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "removed_field" }); + expect(expiryFor(gone, "offers", dates)).toBeNull(); + }); +});