Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions packages/content-freshness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,29 @@ ending in `_excerpt`, `_description`, `_summary` or `_subheadline` therefore
gets the lower priority, and the finding names the field. A site whose
templates always render a description can switch the check off.

The report holds one row per entry that needs attention, most urgent first.
It shows the current state, not a history: an entry is checked again when it
is published, unpublished, scheduled, unscheduled or restored, and leaves the
report as soon as it is trashed or deleted. Other edits show up with the next
audit, which also removes rows of entries that no longer exist.
## Where the findings show up

- **The report** (Content freshness in the admin menu) lists every entry that
needs attention, most urgent first, 25 per page, filtered by collection and
priority. Each entry links straight into its editor.
- **The Freshness panel** in the entry editor shows that entry's findings. It
checks the entry each time it opens, and it is where a finding is set aside:
**Mark as reviewed** holds a stale entry or a forgotten draft back until its
threshold runs out again, so a page that is still correct needs no edit to
quiet the report. **Ignore** holds a description finding back for good. A
missed schedule cannot be set aside. **Undo** brings a finding back.
Authors see the panel on their own entries; the report is for editors and
above.
- **The dashboard widget** counts the entries by priority and says how many
of them are yours.

The report shows the current state, not a history. An entry is checked again
when it is published, unpublished, scheduled, unscheduled or restored, and
whenever its panel opens; it leaves the report as soon as it is trashed or
deleted. Other edits show up with the next audit, which also removes the rows
of entries that no longer exist.

The admin follows the administrator's language: English and German.

Each run audits one page of one collection, and an audit carries on in
follow-up runs until it is done, one per firing of the site's cron trigger,
Expand All @@ -40,7 +58,9 @@ site whose routes differ, and nothing else says so.

`content:read` and `schema:read`, nothing else. Every rule is computed from
fields the content and schema APIs already return, so there is **no network
access** and no `content:write` — the plugin reports, it never edits an entry.
access** and no `content:write`: the plugin reports, it never edits an entry.
What it stores (findings and the findings you set aside) stays in its own
plugin storage.

## Install

Expand Down Expand Up @@ -72,8 +92,9 @@ entry. Without one, the audit is scheduled and never runs.
| Draft forgotten after | 6 months | For drafts. |
| SEO description | 50–160 characters | Outside this range is a low-priority finding. |
| Report entries without an SEO description | on | Off for a site whose templates always render a description. The length check still runs. |
| Entries per run | 50 | At most 100, the most one content query returns. The number of calls per run does not grow with it. |
| Entries per run | 50 | At most 98: D1 binds at most 100 parameters per statement, and storage adds two of its own. The number of calls per run does not grow with it. |
| Schedule | `0 4 * * *` | A cron expression, in UTC. One the scheduler rejects is not saved. |
| Per collection | none | Months for stale entries and forgotten drafts of one collection (empty: the site's value, 0: never), or leave the collection out of the audit. |

## Develop

Expand All @@ -88,10 +109,10 @@ pnpm build

| EmDash | Plugin test suite |
| ------ | ----------------- |
| 0.40.1 | 65 passed |
| 0.40.0 | 65 passed |
| 0.39.1 | 65 passed |
| 0.39.0 | 65 passed |
| 0.40.1 | 99 passed |
| 0.40.0 | 99 passed |
| 0.39.1 | 99 passed |
| 0.39.0 | 99 passed |

Declared range: `emdash >=0.39.0`, with no upper bound. The table covers
**every EmDash release since the floor**. 0.39.0 is the floor because the
Expand Down
6 changes: 5 additions & 1 deletion packages/content-freshness/emdash-plugin.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
// One row per entry with at least one finding. `rank` orders the
// report most urgent first; `seenIn` finds the rows a finished sweep
// did not confirm, which belong to entries that no longer exist.
"findings": { "indexes": ["collection", "rank", "seenIn"] },
"findings": { "indexes": ["collection", "rank", "seenIn", "authorId", ["collection", "rank"]] },
// Findings an editor set aside, one document per entry, keyed like its
// finding row. Read by id only.
"dismissals": { "indexes": [] },
},

"admin": {
Expand All @@ -42,5 +45,6 @@
{ "path": "/settings", "label": "Freshness settings", "icon": "settings" },
],
"widgets": [{ "id": "summary", "title": "Needs attention", "size": "third" }],
"editorPanels": [{ "id": "freshness", "title": "Freshness", "route": "panel" }],
},
}
81 changes: 81 additions & 0 deletions packages/content-freshness/src/check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Checking one entry now, outside the sweep: after a content state change,
* and whenever its editor panel opens.
*/

import type { PluginContext } from "emdash/plugin";

import {
dismissalFor,
isSetAside,
partition,
readDismissals,
writeDismissals,
type Dismissal,
type EntryDismissals,
} from "./dismissals.js";
import { evaluateEntries, forget, storeOne, type EntryFindings, type EntryRef } from "./findings.js";
import { findingId } from "./ids.js";
import { evaluateEntry, type Hit, type Rule } from "./rules.js";
import { readSettings, thresholdsFor } from "./settings.js";

export interface PanelAction {
kind: "set-aside" | "undo";
rule: Rule;
by: string | null;
}

export interface PanelView {
active: Hit[];
setAside: Array<{ hit: Hit; dismissal: Dismissal }>;
/** The entry's collection is left out of the audit. */
skipped?: boolean;
}

/**
* Apply `action`, check the entry, store its row and return what the panel
* shows. Four `ctx` calls; one more for a changed row, one more for an
* action or an expired review.
*/
export async function checkEntry(ctx: PluginContext, ref: EntryRef, action?: PanelAction): Promise<PanelView> {
if (!ctx.content) return { active: [], setAside: [] };
const [settings, entry, stored, existing] = await Promise.all([
readSettings(ctx),
ctx.content.get(ref.collection, ref.id),
readDismissals(ctx, ref.collection, ref.id),
ctx.storage.findings.get(findingId(ref.collection, ref.id)) as Promise<EntryFindings | null>,
]);
const thresholds = thresholdsFor(settings, ref.collection);
if (!entry || thresholds.skip) {
if (existing) await forget(ctx, ref);
return { active: [], setAside: [], skipped: thresholds.skip };
}

const now = new Date();
const rules = { ...stored?.rules };
// Reviews that have run out are dropped whenever the entry is checked.
let changed = false;
for (const rule of Object.keys(rules) as Rule[]) {
if (!isSetAside({ collection: ref.collection, entryId: ref.id, rules }, rule, now)) {
delete rules[rule];
changed = true;
}
}
if (action) {
if (action.kind === "undo") {
delete rules[action.rule];
} else {
const dismissal = dismissalFor(action.rule, thresholds, now, action.by);
if (dismissal) rules[action.rule] = dismissal;
}
changed = true;
}
const dismissals: EntryDismissals = { collection: ref.collection, entryId: ref.id, rules };
if (changed) await writeDismissals(ctx, dismissals);

const keyed = new Map([[findingId(ref.collection, ref.id), dismissals]]);
await storeOne(ctx, evaluateEntries(ref.collection, [entry], thresholds, now, now.toISOString(), keyed), existing);
const { active, setAside } = partition(evaluateEntry(entry, thresholds, now), dismissals, now);
return { active, setAside: setAside.map((hit) => ({ hit, dismissal: dismissals.rules[hit.rule]! })) };
}

28 changes: 18 additions & 10 deletions packages/content-freshness/src/describe.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
/**
* The sentence the report shows for a hit, built from its stored params.
* The sentence shown for a hit, built from its stored params in the
* reader's language.
*/

import type { Hit } from "./rules.js";
import { t, type Lang } from "./i18n.js";
import type { Hit, Severity } from "./rules.js";

export function describeHit(hit: Hit): string {
export function describeHit(lang: Lang, hit: Hit): string {
const { params } = hit;
switch (hit.rule) {
case "overdue-schedule":
return params.kind === "update"
? `Changes scheduled for ${params.date} were never published.`
: `Scheduled for ${params.date} but still ${params.status}.`;
? t(lang, "hitOverdueUpdate", { date: params.date ?? "" })
: t(lang, "hitOverduePublish", { date: params.date ?? "" });
case "missing-description":
return params.field
? `No SEO description; templates often show the "${params.field}" field instead.`
: "Published without an SEO description.";
? t(lang, "hitMissingFallback", { field: params.field })
: t(lang, "hitMissingDescription");
case "description-length":
return `SEO description is ${params.length} characters; aim for ${params.min}–${params.max}.`;
return t(lang, "hitDescriptionLength", params);
case "stale":
return `Not touched since ${params.since}.`;
return t(lang, "hitStale", params);
case "stale-draft":
return `Draft untouched since ${params.since}.`;
return t(lang, "hitStaleDraft", params);
}
}

const SEVERITY_KEY = { high: "priorityHigh", medium: "priorityMedium", low: "priorityLow" } as const;

export function severityText(lang: Lang, severity: Severity): string {
return t(lang, SEVERITY_KEY[severity]);
}
76 changes: 76 additions & 0 deletions packages/content-freshness/src/dismissals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Findings an editor has set aside: one document per entry, under the same
* key as its finding row, so a page of entries needs one `getMany`.
*
* "Mark as reviewed" holds a time-based finding back until the threshold has
* run out again: an imprint that is still correct should not be edited just
* to make the report quiet. "Ignore" holds a description finding back for
* good. A missed schedule cannot be set aside.
*/

import type { PluginContext } from "emdash/plugin";

import { findingId } from "./ids.js";
import { subtractMonths, type Hit, type Rule, type Thresholds } from "./rules.js";

export type DismissKind = "review" | "ignore";

export const DISMISSIBLE: Partial<Record<Rule, DismissKind>> = {
stale: "review",
"stale-draft": "review",
"missing-description": "ignore",
"description-length": "ignore",
};

export interface Dismissal {
/** When the finding may come back; `null` for never. */
until: string | null;
by: string | null;
at: string;
}

export interface EntryDismissals {
collection: string;
entryId: string;
rules: Partial<Record<Rule, Dismissal>>;
}

export function isSetAside(dismissals: EntryDismissals | undefined, rule: Rule, now: Date): boolean {
const dismissal = dismissals?.rules[rule];
if (!dismissal) return false;
return dismissal.until === null || Date.parse(dismissal.until) > now.getTime();
}

/** `hits` split into those that stand and those set aside. */
export function partition(hits: Hit[], dismissals: EntryDismissals | undefined, now: Date) {
const active: Hit[] = [];
const setAside: Hit[] = [];
for (const hit of hits) (isSetAside(dismissals, hit.rule, now) ? setAside : active).push(hit);
return { active, setAside };
}

/** A dismissal for `rule`, or null when the rule cannot be set aside. */
export function dismissalFor(rule: Rule, thresholds: Thresholds, now: Date, by: string | null): Dismissal | null {
const kind = DISMISSIBLE[rule];
if (!kind) return null;
if (kind === "ignore") return { until: null, by, at: now.toISOString() };
const months = rule === "stale" ? thresholds.staleMonths : thresholds.draftMonths;
return { until: subtractMonths(now, -months).toISOString(), by, at: now.toISOString() };
}

export async function readDismissals(
ctx: PluginContext,
collection: string,
entryId: string,
): Promise<EntryDismissals | undefined> {
return ((await ctx.storage.dismissals.get(findingId(collection, entryId))) ?? undefined) as
| EntryDismissals
| undefined;
}

/** Write `dismissals`, or remove the document once nothing is set aside. */
export async function writeDismissals(ctx: PluginContext, dismissals: EntryDismissals): Promise<void> {
const id = findingId(dismissals.collection, dismissals.entryId);
if (Object.keys(dismissals.rules).length === 0) await ctx.storage.dismissals.delete(id);
else await ctx.storage.dismissals.put(id, dismissals);
}
Loading
Loading