From 67bf76e1071c7f4eb1415c5c1e627b6c6dd9c2fe Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 00:51:57 +0200 Subject: [PATCH 1/5] feat(content-freshness): unpublished changes and expired entries Two rules that need nothing the content API does not already return. - Unpublished changes: a published entry whose saved changes have waited in a draft revision for longer than a threshold (14 days by default). A save on a published entry leaves `updatedAt` alone, but revision ids are ULIDs, so the draft revision's id says when the changes were saved. Changes saved within the stale threshold also keep the entry from counting as stale: someone is working on it. "Mark as reviewed" holds the finding back for the threshold. - Expired: a published entry whose expiry date has passed. The field is the collection's first `datetime` field named like valid_until, end_date, expires_at or deadline; each collection's settings can name another field or switch expiry off. A date without a time lasts until the end of that day. "Ignore" holds it back for a page kept online as an archive. The sweep reads the collections' `datetime` fields with the collection list it already fetches; a check of one entry reads its collection once. --- packages/content-freshness/src/check.ts | 14 ++- packages/content-freshness/src/describe.ts | 4 + packages/content-freshness/src/dismissals.ts | 10 +- packages/content-freshness/src/findings.ts | 5 +- packages/content-freshness/src/i18n.ts | 14 +++ packages/content-freshness/src/report.ts | 38 ++++++- packages/content-freshness/src/rules.ts | 85 ++++++++++++++- packages/content-freshness/src/settings.ts | 42 +++++++- packages/content-freshness/src/state.ts | 2 + packages/content-freshness/src/sweep.ts | 32 ++++-- .../content-freshness/tests/expiry.test.ts | 63 +++++++++++ .../content-freshness/tests/rules.test.ts | 100 +++++++++++++++++- .../content-freshness/tests/settings.test.ts | 27 ++++- 13 files changed, 409 insertions(+), 27 deletions(-) create mode 100644 packages/content-freshness/tests/expiry.test.ts diff --git a/packages/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts index 02e8934..e877264 100644 --- a/packages/content-freshness/src/check.ts +++ b/packages/content-freshness/src/check.ts @@ -17,7 +17,7 @@ import { 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 { expiryFor, 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) { @@ -73,9 +74,12 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules }; if (changed) await writeDismissals(ctx, dismissals); + const dateFields = (schema?.fields ?? []).filter((field) => field.type === "datetime"); + const expiry = expiryFor(settings, ref.collection, dateFields); 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, expiry); + await storeOne(ctx, evaluated, existing); + const { active, setAside } = partition(evaluateEntry(entry, thresholds, now, expiry), 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..4b151cc 100644 --- a/packages/content-freshness/src/dismissals.ts +++ b/packages/content-freshness/src/dismissals.ts @@ -4,8 +4,9 @@ * * "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"; @@ -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 { @@ -54,6 +57,9 @@ export function dismissalFor(rule: Rule, thresholds: Thresholds, now: Date, by: const kind = DISMISSIBLE[rule]; if (!kind) return null; if (kind === "ignore") return { until: null, by, at: now.toISOString() }; + if (rule === "unpublished-changes") { + return { until: new Date(now.getTime() + thresholds.pendingDays * 24 * 60 * 60 * 1000).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..2e1578f 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, worstRank, type ExpiryField, 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(), + expiry: ExpiryField | null = null, ): 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, expiry), 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..5f92705 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -62,6 +62,11 @@ 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", + expiryDetected: "Detected: {field}", + expiryNoneDetected: "Detect from the field name (none matches)", + expiryOff: "Never expires", 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 +82,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 +140,11 @@ 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", + expiryDetected: "Erkannt: {field}", + expiryNoneDetected: "Am Feldnamen erkennen (keiner passt)", + expiryOff: "Läuft nie ab", 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 +160,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/report.ts b/packages/content-freshness/src/report.ts index f60708e..56be9fb 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -26,8 +26,8 @@ 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 { detectExpiryField, RANK } from "./rules.js"; +import { collectionField, EXPIRY_AUTO, EXPIRY_OFF, type Settings } from "./settings.js"; import type { State } from "./state.js"; export const VIEW_ACTION = "report_view"; @@ -310,6 +310,31 @@ 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 = item.fields.filter((field) => field.type === "datetime"); + if (dateFields.length === 0) return []; + const detected = 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: detected ? t(lang, "expiryDetected", { field: detected.label }) : t(lang, "expiryNoneDetected"), + value: EXPIRY_AUTO, + }, + ...dateFields.map((field) => ({ label: field.label || field.slug, value: field.slug })), + { label: t(lang, "expiryOff"), value: EXPIRY_OFF }, + ], + initial_value: valid && chosen ? chosen : EXPIRY_AUTO, + }, + ]; +} + function collectionFields(settings: Settings, lang: Lang, collections: PluginCollectionInfo[]): FormField[] { return collections.flatMap((item): FormField[] => { const own = settings.collections[item.slug] ?? {}; @@ -331,6 +356,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 +407,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..ec0ee7f 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -20,6 +20,8 @@ export const RULES = [ "stale", "stale-draft", "description-length", + "unpublished-changes", + "expired", ] as const; export type Rule = (typeof RULES)[number]; @@ -44,6 +46,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 +56,17 @@ 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; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + /** * 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,11 +88,35 @@ 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, + expiry: ExpiryField | null = null, +): Hit[] { const hits: Hit[] = []; const published = entry.status === "published"; const updatedAt = parseDate(entry.updatedAt); + // Changes saved to a published entry wait in a draft revision until they + // are published; publishing or discarding them clears it. Revision ids are + // ULIDs, and a save on a published entry leaves `updatedAt` alone, so the + // id is the only place that says when the changes were saved. + const pendingSince = + published && entry.draftRevisionId && entry.draftRevisionId !== entry.liveRevisionId + ? ulidTime(entry.draftRevisionId) + : null; + if (pendingSince && pendingSince.getTime() < now.getTime() - thresholds.pendingDays * DAY_MS) { + hits.push({ rule: "unpublished-changes", severity: "medium", params: { since: day(pendingSince) } }); + } + + if (published && expiry) { + const ends = expiryDate(entry.data[expiry.slug]); + if (ends && ends.at.getTime() <= now.getTime()) { + hits.push({ rule: "expired", severity: "medium", params: { field: expiry.label, date: ends.day } }); + } + } + // 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 // way someone expected a change to be live, and with autosave the publish @@ -123,9 +160,17 @@ 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. + // not expected to change, such as testimonials. Changes saved within the + // stale threshold mean someone is working on the entry, so it is not + // stale; if they stop, `unpublished-changes` says so. + const beingEdited = pendingSince !== null && pendingSince >= subtractMonths(now, thresholds.staleMonths); if (updatedAt) { - if (published && thresholds.staleMonths > 0 && updatedAt < subtractMonths(now, thresholds.staleMonths)) { + if ( + published && + thresholds.staleMonths > 0 && + !beingEdited && + updatedAt < subtractMonths(now, thresholds.staleMonths) + ) { hits.push({ rule: "stale", severity: "medium", params: { since: day(updatedAt) } }); } if (entry.status === "draft" && thresholds.draftMonths > 0 && updatedAt < subtractMonths(now, thresholds.draftMonths)) { @@ -157,6 +202,40 @@ 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); +} + +/** + * When a `datetime` value runs out, and the day to name. A bare date, as a + * date-only widget stores it, lasts until the end of that day. + */ +export function expiryDate(value: unknown): { at: Date; day: string } | null { + if (typeof value !== "string") return null; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { + const start = parseDate(`${value}T00:00:00.000Z`); + return start ? { at: new Date(start.getTime() + DAY_MS), day: value } : null; + } + const at = parseDate(value); + return at ? { at, day: day(at) } : null; +} + +/** Field names that say when an entry stops being current. */ +const EXPIRY_NAME = + /(?:^|_)(?:end|ends|until|expires?|expiry|deadline|closes?|closing)(?:_at|_date|_on|_time)?$/; + +/** The first `datetime` field whose name says when an entry runs out. */ +export function detectExpiryField(fields: Array<{ slug: string; label: string; type: string }>): ExpiryField | null { + const field = fields.find((candidate) => candidate.type === "datetime" && EXPIRY_NAME.test(candidate.slug)); + return field ? { slug: field.slug, label: field.label || field.slug } : null; +} + /** 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..05cada0 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 ExpiryField, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; @@ -25,6 +25,8 @@ export const SETTINGS_KEY = "settings"; export interface CollectionOverride { staleMonths?: number; draftMonths?: number; + /** The `datetime` field entries expire after, or `"off"`. Unset: detected from the field names. */ + expiryField?: string; /** Leave the collection out of the audit altogether. */ skip?: boolean; } @@ -47,7 +49,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 "detect it from the field names". */ +export const EXPIRY_AUTO = "auto"; +/** The expiry select's value for "entries of this collection never expire". */ +export const EXPIRY_OFF = "off"; export function collectionField(slug: string, setting: keyof CollectionOverride): string { return `collection:${slug}:${setting}`; @@ -62,11 +69,29 @@ 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: the one the settings name, + * none when they say off, else the first whose name says so. `fields` are the + * collection's `datetime` fields. + */ +export function expiryFor( + settings: Settings, + collection: string, + fields: Array<{ slug: string; label: string }>, +): ExpiryField | null { + const chosen = settings.collections[collection]?.expiryField; + if (chosen === EXPIRY_OFF) return null; + const named = chosen ? fields.find((field) => field.slug === chosen) : undefined; + if (named) return { slug: named.slug, label: named.label || named.slug }; + return detectExpiryField(fields.map((field) => ({ ...field, type: "datetime" }))); +} + +type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax" | "pendingDays"; const NUMBERS: Array<[NumberKey, number, number]> = [ ["pageSize", 1, ID_BATCH], @@ -74,6 +99,7 @@ const NUMBERS: Array<[NumberKey, number, number]> = [ ["draftMonths", 1, 120], ["descriptionMin", 0, 300], ["descriptionMax", 0, 300], + ["pendingDays", 1, 365], ]; export interface StoredSettings { @@ -121,13 +147,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 +207,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_AUTO) 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..5e9f740 100644 --- a/packages/content-freshness/src/state.ts +++ b/packages/content-freshness/src/state.ts @@ -16,6 +16,8 @@ export interface Sweep { /** `content.list` cursor within `collections[index]`. */ cursor: string | null; phase: "audit" | "cleanup"; + /** Each collection's `datetime` fields, read with the collection list, for the expiry rule. */ + dateFields?: Record>; } export interface State { diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index 5c01f22..abebe19 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -21,7 +21,7 @@ import type { PluginContext } from "emdash/plugin"; import type { EntryDismissals } from "./dismissals.js"; import { evaluateEntries, store } from "./findings.js"; import { findingId, ID_BATCH } from "./ids.js"; -import { readSettings, thresholdsFor, type Settings } from "./settings.js"; +import { expiryFor, readSettings, thresholdsFor, type Settings } from "./settings.js"; import { AUDIT_TASK } from "./schedule.js"; import { readState, writeState, type State, type Sweep } from "./state.js"; @@ -69,10 +69,22 @@ 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 dateFields: NonNullable = {}; + for (const collection of audited) { + const fields = collection.fields + .filter((field) => field.type === "datetime") + .map((field) => ({ slug: field.slug, label: field.label })); + if (fields.length > 0) dateFields[collection.slug] = fields; + } + return { + startedAt: now.toISOString(), + collections: audited.map((collection) => collection.slug), + index: 0, + cursor: null, + phase: "audit", + dateFields, + }; } function abandoned(sweep: Sweep, now: Date): boolean { @@ -145,7 +157,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, + expiryFor(settings, collection, sweep.dateFields?.[collection] ?? []), + ), ); if (page.cursor) { diff --git a/packages/content-freshness/tests/expiry.test.ts b/packages/content-freshness/tests/expiry.test.ts new file mode 100644 index 0000000..99cf2e9 --- /dev/null +++ b/packages/content-freshness/tests/expiry.test.ts @@ -0,0 +1,63 @@ +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 } from "../src/panel.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" }], + }); + const entry = await runtime.fixtures.content("offers", { + slug: "summer-sale", + data: { valid_until: "2026-01-31" }, + status: "published", + }); + return { runtime, entry }; +} + +describe("an entry whose expiry date has passed", () => { + it("is found by the sweep through the detected field", async () => { + const { runtime, entry } = await offers(); + host = runtime; + await runSweep(runtime); + + const row = await finding(runtime, "offers", entry.id); + expect(row?.hits).toContainEqual(expect.objectContaining({ rule: "expired", params: expect.objectContaining({ field: "Valid until" }) })); + }); + + it("shows in its panel, where it can be ignored", async () => { + const { runtime, entry } = await offers(); + host = runtime; + const panel = await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); + expect(validateBlocks(panel.blocks).valid).toBe(true); + expect(JSON.stringify(panel.blocks)).toContain("Valid until was 2026-01-31"); + expect(JSON.stringify(panel.blocks)).toContain("Ignore"); + }); + + it("lets the settings name the field, or switch expiry off", async () => { + const { runtime, entry } = await offers(); + host = runtime; + const page = await runtime.admin.loadPage("/settings"); + expect(validateBlocks(page.blocks).valid).toBe(true); + expect(JSON.stringify(page.blocks)).toContain("Detected: Valid until"); + + await runtime.admin.submit("/settings", "save_settings", { "collection:offers:expiryField": "off" }); + await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); + expect(await finding(runtime, "offers", entry.id)).toBeNull(); + }); +}); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index 5edfff8..39ca482 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,94 @@ 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(); + }); +}); + +describe("unpublished changes", () => { + const pending = (savedAt: string, extra = {}) => + entry({ draftRevisionId: ulidAt(savedAt), liveRevisionId: ulidAt("2026-01-01T00:00:00.000Z"), ...extra }); + + it("are reported once they have waited longer than the threshold", () => { + const hits = evaluateEntry(pending("2026-08-01T00:00:00.000Z"), DEFAULT_THRESHOLDS, NOW); + 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(rules(pending("2026-09-01T00:00:00.000Z"))).not.toContain("unpublished-changes"); + const live = ulidAt("2026-06-01T00:00:00.000Z"); + expect(rules(entry({ draftRevisionId: live, liveRevisionId: live }))).not.toContain("unpublished-changes"); + expect(rules(pending("2026-06-01T00:00:00.000Z", { status: "draft" }))).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(rules(pending("2026-09-01T00:00:00.000Z", old))).not.toContain("stale"); + // Changes older than the stale threshold do not. + expect(rules(pending("2025-01-01T00:00:00.000Z", old))).toContain("stale"); + }); +}); + +describe("expired entries", () => { + const expiry = { slug: "valid_until", label: "Valid until" }; + const until = (value: unknown, extra = {}) => + evaluateEntry(entry({ data: { valid_until: value }, ...extra }), DEFAULT_THRESHOLDS, NOW, expiry).map((hit) => hit.rule); + + it("are reported after the day in their expiry field", () => { + const [hit] = evaluateEntry(entry({ data: { valid_until: "2026-09-07" } }), DEFAULT_THRESHOLDS, NOW, expiry); + expect(hit).toEqual({ rule: "expired", severity: "medium", params: { field: "Valid until", date: "2026-09-07" } }); + expect(until("2026-09-01T12:00:00.000Z")).toContain("expired"); + }); + + it("are not reported on the day itself, while unpublished, or without an expiry field", () => { + // NOW is the start of 2026-09-08: a bare date lasts until the day ends. + expect(until("2026-09-08")).not.toContain("expired"); + expect(until("2026-09-01", { status: "draft" })).not.toContain("expired"); + expect(until(42)).not.toContain("expired"); + expect(evaluateEntry(entry({ data: { valid_until: "2026-09-01" } }), DEFAULT_THRESHOLDS, NOW).map((hit) => hit.rule)).not.toContain( + "expired", + ); + }); +}); + +describe("detectExpiryField", () => { + it("picks the first datetime field whose name says when an entry runs out", () => { + const fields = [ + { slug: "start_date", label: "Starts", type: "datetime" }, + { slug: "end_label", label: "End label", type: "string" }, + { slug: "valid_until", label: "Valid until", type: "datetime" }, + ]; + expect(detectExpiryField(fields)).toEqual({ slug: "valid_until", label: "Valid until" }); + }); + + it("recognises the usual names and nothing else", () => { + for (const slug of ["end_date", "ends_on", "event_end", "expires_at", "expiry", "deadline", "closing_date", "offer_until"]) { + expect(detectExpiryField([{ slug, label: slug, type: "datetime" }])?.slug).toBe(slug); + } + for (const slug of ["start_date", "published_on", "weekend", "friend_date"]) { + expect(detectExpiryField([{ slug, label: slug, type: "datetime" }])).toBeNull(); + } + }); +}); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 9695f90..4edafb9 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, thresholdsFor } from "../src/settings.js"; +import { applyForm, DEFAULT_SETTINGS, expiryFor, normalizeSettings, thresholdsFor } from "../src/settings.js"; describe("normalizeSettings", () => { it("returns defaults for nothing stored", () => { @@ -72,3 +72,28 @@ 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 detected from the field names unless the settings say otherwise", () => { + expect(expiryFor(DEFAULT_SETTINGS, "offers", dates)).toEqual({ slug: "valid_until", label: "Valid until" }); + }); + + it("can be chosen, or switched off", () => { + const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "starts_on" }); + expect(expiryFor(chosen, "offers", dates)).toEqual({ slug: "starts_on", label: "Starts" }); + const off = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "off" }); + expect(expiryFor(off, "offers", dates)).toBeNull(); + }); + + it("goes back to detection for auto, or for a field the collection no longer has", () => { + const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "starts_on" }); + expect(applyForm(chosen, { "collection:offers:expiryField": "auto" }).collections).toEqual({}); + const gone = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "removed_field" }); + expect(expiryFor(gone, "offers", dates)?.slug).toBe("valid_until"); + }); +}); From eb5c7e08a72720c939f37ce55e221a9fe2fba88b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 00:51:57 +0200 Subject: [PATCH 2/5] docs(content-freshness): the two new rules and their settings --- packages/content-freshness/README.md | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 2d23738..8de1eee 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -10,9 +10,18 @@ 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 | should fix | | SEO description too short or too long | nice to fix | | Draft abandoned for longer than your threshold | nice to fix | +An entry with changes saved recently is not reported as stale: someone is +working on it, and if they stop, the unpublished changes are reported +instead. The expiry field is the collection's first `datetime` field named +like `valid_until`, `end_date`, `expires_at` or `deadline`; the settings can +name another field or switch expiry off for a collection. A date without a +time lasts until the end of that day. + 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 +36,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 +101,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 From bc0eee5e2e3f27a91c251c8c9ef2edcb23247147 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 01:16:38 +0200 Subject: [PATCH 3/5] fix(content-freshness): findings from the review of the two new rules - EmDash stores every datetime value as a UTC instant, reading one without a time as midnight in the site's timezone; a datetime field has no date-only mode. The "bare date lasts to the end of the day" branch therefore never ran, and the finding named the UTC day, a day early for sites east of UTC. A plugin cannot read the site's timezone, so the instant is compared as it is and named to the minute in UTC. - Expiry is opt-in per collection. Detected automatically, it would have given a site that keeps past events online a finding for each of them on install. The report suggests a field named like an expiry date until the collection has one chosen or is set to "Never expires". - An ignored expiry remembers its date, so moving the date forward and letting it pass again is reported. - Unpublished changes count only in collections that keep revisions, where a draft revision means staged changes, and not while they are scheduled to go live, which is overdue-schedule's to report. - Stale drafts are dated by their last save: in a collection with revisions a save leaves `updatedAt` alone, so a draft edited yesterday looked untouched since its creation. - A sweep carries a version; one started by an earlier version of the plugin starts over instead of auditing without the collection details. - More expiry field names (expiration, valid_to, valid_through, *_datetime), and one helper for a collection's datetime fields. --- packages/content-freshness/src/check.ts | 26 ++-- packages/content-freshness/src/dismissals.ts | 44 ++++-- packages/content-freshness/src/findings.ts | 6 +- packages/content-freshness/src/i18n.ts | 16 ++- packages/content-freshness/src/plugin.ts | 11 +- packages/content-freshness/src/report.ts | 56 ++++++-- packages/content-freshness/src/rules.ts | 127 +++++++++++------- packages/content-freshness/src/settings.ts | 50 ++++--- packages/content-freshness/src/state.ts | 16 ++- packages/content-freshness/src/sweep.ts | 26 ++-- .../content-freshness/tests/audit.test.ts | 29 +++- .../content-freshness/tests/budget.test.ts | 4 +- .../content-freshness/tests/expiry.test.ts | 62 ++++++--- .../content-freshness/tests/report.test.ts | 18 +-- .../content-freshness/tests/routing.test.ts | 7 +- .../content-freshness/tests/rules.test.ts | 101 ++++++++++---- .../content-freshness/tests/settings.test.ts | 29 ++-- 17 files changed, 433 insertions(+), 195 deletions(-) diff --git a/packages/content-freshness/src/check.ts b/packages/content-freshness/src/check.ts index e877264..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 { expiryFor, 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"; @@ -53,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; } @@ -66,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,12 +82,10 @@ export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: Pan const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules }; if (changed) await writeDismissals(ctx, dismissals); - const dateFields = (schema?.fields ?? []).filter((field) => field.type === "datetime"); - const expiry = expiryFor(settings, ref.collection, dateFields); const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]); - const evaluated = evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed, expiry); + const evaluated = evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed, context); await storeOne(ctx, evaluated, existing); - const { active, setAside } = partition(evaluateEntry(entry, thresholds, now, expiry), dismissals, now); + 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/dismissals.ts b/packages/content-freshness/src/dismissals.ts index 4b151cc..e357790 100644 --- a/packages/content-freshness/src/dismissals.ts +++ b/packages/content-freshness/src/dismissals.ts @@ -12,7 +12,7 @@ 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"; @@ -30,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 { @@ -38,27 +44,45 @@ 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 * 24 * 60 * 60 * 1000).toISOString(), by, at: now.toISOString() }; + 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 2e1578f..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 ExpiryField, 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,12 +48,12 @@ export function evaluateEntries( now: Date, seenIn: string, dismissals: Map = new Map(), - expiry: ExpiryField | null = null, + 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, expiry), 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 5f92705..5e958bf 100644 --- a/packages/content-freshness/src/i18n.ts +++ b/packages/content-freshness/src/i18n.ts @@ -64,9 +64,13 @@ const en = { schedule: "Schedule (cron, UTC)", pendingDays: "Days before unpublished changes to a published entry count as forgotten", collectionExpiry: "{collection}: expires after the date in", - expiryDetected: "Detected: {field}", - expiryNoneDetected: "Detect from the field name (none matches)", + 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", @@ -142,9 +146,13 @@ const de: Record = { 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", - expiryDetected: "Erkannt: {field}", - expiryNoneDetected: "Am Feldnamen erkennen (keiner passt)", + 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", 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 56be9fb..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 { detectExpiryField, RANK } from "./rules.js"; -import { collectionField, EXPIRY_AUTO, EXPIRY_OFF, 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, @@ -312,9 +320,9 @@ 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 = item.fields.filter((field) => field.type === "datetime"); + const dateFields = datetimeFields(item.fields); if (dateFields.length === 0) return []; - const detected = detectExpiryField(dateFields); + const suggested = detectExpiryField(dateFields); const chosen = settings.collections[item.slug]?.expiryField; const valid = chosen === EXPIRY_OFF || dateFields.some((field) => field.slug === chosen); return [ @@ -323,14 +331,40 @@ function expirySelect(settings: Settings, lang: Lang, item: PluginCollectionInfo action_id: collectionField(item.slug, "expiryField"), label: t(lang, "collectionExpiry", { collection }), options: [ - { - label: detected ? t(lang, "expiryDetected", { field: detected.label }) : t(lang, "expiryNoneDetected"), - value: EXPIRY_AUTO, - }, - ...dateFields.map((field) => ({ label: field.label || field.slug, value: field.slug })), + { 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_AUTO, + 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" } }], }, ]; } diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index ec0ee7f..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 @@ -65,7 +67,17 @@ export interface ExpiryField { label: string; } -const DAY_MS = 24 * 60 * 60 * 1000; +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 @@ -92,28 +104,42 @@ export function evaluateEntry( entry: PluginContentItem, thresholds: Thresholds, now: Date, - expiry: ExpiryField | null = null, + context: EntryContext = NO_CONTEXT, ): Hit[] { const hits: Hit[] = []; const published = entry.status === "published"; - const updatedAt = parseDate(entry.updatedAt); - - // Changes saved to a published entry wait in a draft revision until they - // are published; publishing or discarding them clears it. Revision ids are - // ULIDs, and a save on a published entry leaves `updatedAt` alone, so the - // id is the only place that says when the changes were saved. - const pendingSince = - published && entry.draftRevisionId && entry.draftRevisionId !== entry.liveRevisionId + + // 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; - if (pendingSince && pendingSince.getTime() < now.getTime() - thresholds.pendingDays * DAY_MS) { - hits.push({ rule: "unpublished-changes", severity: "medium", params: { since: day(pendingSince) } }); + 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) } }); } - if (published && expiry) { - const ends = expiryDate(entry.data[expiry.slug]); - if (ends && ends.at.getTime() <= now.getTime()) { - hits.push({ rule: "expired", severity: "medium", params: { field: expiry.label, date: ends.day } }); + // 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) } }); } } @@ -160,21 +186,15 @@ export function evaluateEntry( } // A threshold of 0 switches the rule off: a collection whose entries are - // not expected to change, such as testimonials. Changes saved within the - // stale threshold mean someone is working on the entry, so it is not - // stale; if they stop, `unpublished-changes` says so. - const beingEdited = pendingSince !== null && pendingSince >= subtractMonths(now, thresholds.staleMonths); - if (updatedAt) { - if ( - published && - thresholds.staleMonths > 0 && - !beingEdited && - 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) } }); } } @@ -212,28 +232,31 @@ export function ulidTime(id: string | null | undefined): Date | null { return new Date(ms); } -/** - * When a `datetime` value runs out, and the day to name. A bare date, as a - * date-only widget stores it, lasts until the end of that day. - */ -export function expiryDate(value: unknown): { at: Date; day: string } | null { - if (typeof value !== "string") return null; - if (/^\d{4}-\d{2}-\d{2}$/.test(value)) { - const start = parseDate(`${value}T00:00:00.000Z`); - return start ? { at: new Date(start.getTime() + DAY_MS), day: value } : null; - } - const at = parseDate(value); - return at ? { at, day: day(at) } : null; -} - /** Field names that say when an entry stops being current. */ const EXPIRY_NAME = - /(?:^|_)(?:end|ends|until|expires?|expiry|deadline|closes?|closing)(?:_at|_date|_on|_time)?$/; + /(?:^|_)(?: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; +} -/** The first `datetime` field whose name says when an entry runs out. */ -export function detectExpiryField(fields: Array<{ slug: string; label: string; type: string }>): ExpiryField | null { - const field = fields.find((candidate) => candidate.type === "datetime" && EXPIRY_NAME.test(candidate.slug)); - return field ? { slug: field.slug, label: field.label || field.slug } : null; +/** 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. */ diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 05cada0..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, detectExpiryField, type ExpiryField, type Thresholds } from "./rules.js"; +import { DEFAULT_THRESHOLDS, detectExpiryField, type EntryContext, type ExpiryField, type Thresholds } from "./rules.js"; export const SETTINGS_KEY = "settings"; @@ -25,7 +25,10 @@ export const SETTINGS_KEY = "settings"; export interface CollectionOverride { staleMonths?: number; draftMonths?: number; - /** The `datetime` field entries expire after, or `"off"`. Unset: detected from the field names. */ + /** + * 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; @@ -51,9 +54,9 @@ 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|expiryField|skip)$/; -/** The expiry select's value for "detect it from the field names". */ -export const EXPIRY_AUTO = "auto"; -/** The expiry select's value for "entries of this collection never expire". */ +/** 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 { @@ -75,20 +78,33 @@ export function thresholdsFor(settings: Settings, collection: string): Threshold } /** - * The field `collection`'s entries expire after: the one the settings name, - * none when they say off, else the first whose name says so. `fields` are the - * collection's `datetime` fields. + * 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( +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, - fields: Array<{ slug: string; label: string }>, -): ExpiryField | null { - const chosen = settings.collections[collection]?.expiryField; - if (chosen === EXPIRY_OFF) return null; - const named = chosen ? fields.find((field) => field.slug === chosen) : undefined; - if (named) return { slug: named.slug, label: named.label || named.slug }; - return detectExpiryField(fields.map((field) => ({ ...field, type: "datetime" }))); + 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"; @@ -208,7 +224,7 @@ export function applyForm(current: Settings, values: Record, sh const own = (collections[slug] ??= {}); if (setting === "skip") own.skip = value === true; else if (setting === "expiryField") { - if (typeof value === "string" && value !== EXPIRY_AUTO) own.expiryField = value; + 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]; diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts index 5e9f740..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,8 +28,8 @@ export interface Sweep { /** `content.list` cursor within `collections[index]`. */ cursor: string | null; phase: "audit" | "cleanup"; - /** Each collection's `datetime` fields, read with the collection list, for the expiry rule. */ - dateFields?: Record>; + /** 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 abebe19..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 { expiryFor, 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 audited = (await listCollections(ctx)).filter((collection) => !thresholdsFor(settings, collection.slug).skip); - const dateFields: NonNullable = {}; + const info: Record = {}; for (const collection of audited) { - const fields = collection.fields - .filter((field) => field.type === "datetime") - .map((field) => ({ slug: field.slug, label: field.label })); - if (fields.length > 0) dateFields[collection.slug] = fields; + 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", - dateFields, + info, }; } @@ -164,7 +170,7 @@ async function auditPage( now, sweep.startedAt, dismissals as Map, - expiryFor(settings, collection, sweep.dateFields?.[collection] ?? []), + contextFor(settings, collection, sweep.info[collection]), ), ); 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 index 99cf2e9..a7bfcb5 100644 --- a/packages/content-freshness/tests/expiry.test.ts +++ b/packages/content-freshness/tests/expiry.test.ts @@ -2,7 +2,8 @@ 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 } from "../src/panel.js"; +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; @@ -22,42 +23,67 @@ async function offers() { 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-31" }, + data: { valid_until: "2026-01-30T23:00:00.000Z" }, status: "published", }); return { runtime, entry }; } -describe("an entry whose expiry date has passed", () => { - it("is found by the sweep through the detected field", async () => { +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); - const row = await finding(runtime, "offers", entry.id); - expect(row?.hits).toContainEqual(expect.objectContaining({ rule: "expired", params: expect.objectContaining({ field: "Valid until" }) })); + 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("shows in its panel, where it can be ignored", async () => { + it("reports an expired entry once the field is chosen, naming the instant in UTC", async () => { const { runtime, entry } = await offers(); host = runtime; - const panel = await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); - expect(validateBlocks(panel.blocks).valid).toBe(true); - expect(JSON.stringify(panel.blocks)).toContain("Valid until was 2026-01-31"); - expect(JSON.stringify(panel.blocks)).toContain("Ignore"); + 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("lets the settings name the field, or switch expiry off", async () => { + it("comes back after an ignore once the date moved and passed again", async () => { const { runtime, entry } = await offers(); host = runtime; - const page = await runtime.admin.loadPage("/settings"); - expect(validateBlocks(page.blocks).valid).toBe(true); - expect(JSON.stringify(page.blocks)).toContain("Detected: Valid until"); - - await runtime.admin.submit("/settings", "save_settings", { "collection:offers:expiryField": "off" }); - await runtime.admin.loadEditorPanel(PANEL_ID, "offers", entry.id); + 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 39ca482..2ddb0a2 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -204,68 +204,111 @@ describe("ulidTime", () => { }); }); +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); + 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(rules(pending("2026-09-01T00:00:00.000Z"))).not.toContain("unpublished-changes"); + expect(found(pending("2026-09-01T00:00:00.000Z"))).not.toContain("unpublished-changes"); const live = ulidAt("2026-06-01T00:00:00.000Z"); - expect(rules(entry({ draftRevisionId: live, liveRevisionId: live }))).not.toContain("unpublished-changes"); - expect(rules(pending("2026-06-01T00:00:00.000Z", { status: "draft" }))).not.toContain("unpublished-changes"); + 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(rules(pending("2026-09-01T00:00:00.000Z", old))).not.toContain("stale"); + expect(found(pending("2026-09-01T00:00:00.000Z", old))).not.toContain("stale"); // Changes older than the stale threshold do not. - expect(rules(pending("2025-01-01T00:00:00.000Z", old))).toContain("stale"); + 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 expiry = { slug: "valid_until", label: "Valid until" }; + 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, expiry).map((hit) => hit.rule); + evaluateEntry(entry({ data: { valid_until: value }, ...extra }), DEFAULT_THRESHOLDS, NOW, context).map( + (hit) => hit.rule, + ); - it("are reported after the day in their expiry field", () => { - const [hit] = evaluateEntry(entry({ data: { valid_until: "2026-09-07" } }), DEFAULT_THRESHOLDS, NOW, expiry); - expect(hit).toEqual({ rule: "expired", severity: "medium", params: { field: "Valid until", date: "2026-09-07" } }); - expect(until("2026-09-01T12:00:00.000Z")).toContain("expired"); + 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 on the day itself, while unpublished, or without an expiry field", () => { - // NOW is the start of 2026-09-08: a bare date lasts until the day ends. - expect(until("2026-09-08")).not.toContain("expired"); - expect(until("2026-09-01", { status: "draft" })).not.toContain("expired"); + 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(evaluateEntry(entry({ data: { valid_until: "2026-09-01" } }), DEFAULT_THRESHOLDS, NOW).map((hit) => hit.rule)).not.toContain( - "expired", - ); + expect(rules(entry({ data: { valid_until: "2026-09-01T00:00:00.000Z" } }))).not.toContain("expired"); }); }); describe("detectExpiryField", () => { - it("picks the first datetime field whose name says when an entry runs out", () => { - const fields = [ - { slug: "start_date", label: "Starts", type: "datetime" }, - { slug: "end_label", label: "End label", type: "string" }, - { slug: "valid_until", label: "Valid until", type: "datetime" }, - ]; - expect(detectExpiryField(fields)).toEqual({ slug: "valid_until", label: "Valid until" }); + 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", "expires_at", "expiry", "deadline", "closing_date", "offer_until"]) { - expect(detectExpiryField([{ slug, label: slug, type: "datetime" }])?.slug).toBe(slug); + 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([{ slug, label: slug, type: "datetime" }])).toBeNull(); + expect(detectExpiryField(fields(slug))).toBeNull(); } }); }); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 4edafb9..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, expiryFor, 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", () => { @@ -79,21 +86,25 @@ describe("the expiry field of a collection", () => { { slug: "valid_until", label: "Valid until" }, ]; - it("is detected from the field names unless the settings say otherwise", () => { - expect(expiryFor(DEFAULT_SETTINGS, "offers", dates)).toEqual({ 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("can be chosen, or switched off", () => { - const chosen = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "starts_on" }); - expect(expiryFor(chosen, "offers", dates)).toEqual({ slug: "starts_on", label: "Starts" }); + 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 detection for auto, or for a field the collection no longer has", () => { + 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": "auto" }).collections).toEqual({}); + expect(applyForm(chosen, { "collection:offers:expiryField": "unset" }).collections).toEqual({}); const gone = applyForm(DEFAULT_SETTINGS, { "collection:offers:expiryField": "removed_field" }); - expect(expiryFor(gone, "offers", dates)?.slug).toBe("valid_until"); + expect(expiryFor(gone, "offers", dates)).toBeNull(); }); }); From 8fb113a14df058bd66334d8d3c23e5913ce9077c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 01:16:38 +0200 Subject: [PATCH 4/5] docs(content-freshness): expiry is opt-in and named in UTC --- packages/content-freshness/README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 8de1eee..4b31941 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -11,16 +11,23 @@ What it looks for: | 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 | 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 | -An entry with changes saved recently is not reported as stale: someone is -working on it, and if they stop, the unpublished changes are reported -instead. The expiry field is the collection's first `datetime` field named -like `valid_until`, `end_date`, `expires_at` or `deadline`; the settings can -name another field or switch expiry off for a collection. A date without a -time lasts until the end of that day. +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`, From 9e0272ae324f8cd3440a8c58409bff015bbc404b Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 26 Sep 2026 01:30:20 +0200 Subject: [PATCH 5/5] docs(content-freshness): 119 tests on every EmDash release from 0.39.0 to 0.40.1 --- packages/content-freshness/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 4b31941..24f4a0a 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -128,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