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
30 changes: 18 additions & 12 deletions packages/content-freshness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ What it looks for:

| Finding | Priority |
| --- | --- |
| Scheduled to publish, the date passed, still not published | urgent |
| 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 |
| SEO description too short or too long | nice to fix |
Expand All @@ -20,12 +20,16 @@ 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.

A fixed entry drops out of the report on the next sweep — the audit stores the
current state, not a history.
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.

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,
instead of waiting a day per collection.
instead of waiting a day per collection. Every run stays within the ten
subrequests EmDash allows a sandboxed plugin per invocation.

The report and the dashboard widget also warn about routable collections
without a URL pattern. EmDash then links their entries as
Expand All @@ -51,8 +55,10 @@ emdash({ plugins: [contentFreshness] });

Or under `sandboxed: []` if the site has a sandbox runner configured.

Then activate it once under **Admin → Extensions**. Every collection on the
site is audited.
Every collection on the site is audited. The nightly audit is registered the
first time the dashboard or the report is opened, so an install needs no
further step. **Audit now** on the report starts one at the next firing of
the site's cron trigger.

**The site needs a cron trigger.** On Cloudflare that is `"crons"` in
`wrangler.jsonc` plus `scheduled: createScheduledHandler()` on the Worker
Expand All @@ -66,8 +72,8 @@ 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 | One collection list, one `content.list` and two batched storage calls per run, whatever the page size. |
| Schedule | `0 4 * * *` | Any cron expression. |
| Entries per run | 50 | At most 100, the most one content query returns. The number of calls per run does not grow with it. |
| Schedule | `0 4 * * *` | A cron expression, in UTC. One the scheduler rejects is not saved. |

## Develop

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

| EmDash | Plugin test suite |
| ------ | ----------------- |
| 0.40.1 | 38 passed |
| 0.40.0 | 38 passed |
| 0.39.1 | 38 passed |
| 0.39.0 | 38 passed |
| 0.40.1 | 65 passed |
| 0.40.0 | 65 passed |
| 0.39.1 | 65 passed |
| 0.39.0 | 65 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
7 changes: 4 additions & 3 deletions packages/content-freshness/emdash-plugin.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@
"allowedHosts": [],

"storage": {
// One row per (entry, rule). Re-running overwrites, so a fixed
// entry drops out of the report on the next sweep.
"findings": { "indexes": ["collection", "severity", "rule", "entryId"] },
// 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"] },
},

"admin": {
Expand Down
1 change: 1 addition & 0 deletions packages/content-freshness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
},
"devDependencies": {
"@eisbachcode/emdash-plugin-shared": "workspace:*",
"@emdash-cms/blocks": "0.39.0",
"@emdash-cms/plugin-cli": "0.12.0",
"@emdash-cms/plugin-test": "0.2.3",
"emdash": ">=0.39.0 <1.0.0",
Expand Down
25 changes: 25 additions & 0 deletions packages/content-freshness/src/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* The sentence the report shows for a hit, built from its stored params.
*/

import type { Hit } from "./rules.js";

export function describeHit(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}.`;
case "missing-description":
return params.field
? `No SEO description; templates often show the "${params.field}" field instead.`
: "Published without an SEO description.";
case "description-length":
return `SEO description is ${params.length} characters; aim for ${params.min}–${params.max}.`;
case "stale":
return `Not touched since ${params.since}.`;
case "stale-draft":
return `Draft untouched since ${params.since}.`;
}
}
73 changes: 73 additions & 0 deletions packages/content-freshness/src/findings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Stored findings: one row per entry that has at least one hit.
*
* An entry without hits has no row, so a fixed entry leaves the report as
* soon as it is evaluated again. `rank` is the entry's most urgent severity
* and is indexed, which lets the report page through the most urgent rows
* first instead of sorting whatever the first page happened to hold.
*/

import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared";
import type { PluginContext } from "emdash/plugin";

import { evaluateEntry, worstRank, type Hit, type Thresholds } from "./rules.js";

export interface EntryFindings {
collection: string;
entryId: string;
slug: string | null;
locale: string | null;
rank: number;
hits: Hit[];
entryUpdatedAt: string;
/** When the row was written: a sweep's start, or the time of a content hook. */
seenIn: string;
}

export function findingId(collection: string, entryId: string): string {
return `${collection}:${entryId}`;
}

export interface Evaluated {
writes: Array<{ id: string; data: EntryFindings }>;
/** Entries evaluated clean, whose rows must go. */
clears: string[];
}

export function evaluateEntries(
collection: string,
entries: PluginContentItem[],
thresholds: Thresholds,
now: Date,
seenIn: string,
): Evaluated {
const result: Evaluated = { writes: [], clears: [] };
for (const entry of entries) {
const id = findingId(collection, entry.id);
const hits = evaluateEntry(entry, thresholds, now);
if (hits.length === 0) {
result.clears.push(id);
continue;
}
result.writes.push({
id,
data: {
collection,
entryId: entry.id,
slug: entry.slug,
locale: entry.locale ?? null,
rank: worstRank(hits),
hits,
entryUpdatedAt: entry.updatedAt,
seenIn,
},
});
}
return result;
}

/** One `putMany` and one `deleteMany` at most, whatever the number of entries. */
export async function store(ctx: PluginContext, evaluated: Evaluated): Promise<void> {
if (evaluated.writes.length > 0) await ctx.storage.findings.putMany(evaluated.writes);
if (evaluated.clears.length > 0) await ctx.storage.findings.deleteMany(evaluated.clears);
}
51 changes: 51 additions & 0 deletions packages/content-freshness/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Keeping one entry's row current between sweeps.
*
* Publishing, unpublishing, scheduling and restoring re-evaluate the entry;
* trashing or deleting it removes its row. `content:afterSave` is left out
* on purpose: it also fires for every autosave, which for a sandboxed
* install would start an isolate every few seconds while someone types.
* A save without a state change is picked up by the next sweep.
*/

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

import { evaluateEntries, findingId, store } from "./findings.js";
import { readSettings } from "./settings.js";

interface EntryRef {
collection: string;
id: string;
}

/** The entry a state-change event is about: `{ content, collection }`, with the id on `content`. */
export function changedEntry(event: unknown): EntryRef | null {
if (typeof event !== "object" || event === null) return null;
const { content, collection } = event as { content?: unknown; collection?: unknown };
if (typeof collection !== "string" || typeof content !== "object" || content === null) return null;
const id = (content as { id?: unknown }).id;
return typeof id === "string" ? { collection, id } : null;
}

/** The entry a delete event is about: `{ id, collection }`. */
export function deletedEntry(event: unknown): EntryRef | null {
if (typeof event !== "object" || event === null) return null;
const { id, collection } = event as { id?: unknown; collection?: unknown };
return typeof id === "string" && typeof collection === "string" ? { collection, id } : null;
}

/** Evaluate one entry now and store the result. Three `ctx` calls. */
export async function reevaluate(ctx: PluginContext, ref: EntryRef): Promise<void> {
if (!ctx.content) return;
const [settings, entry] = await Promise.all([readSettings(ctx), ctx.content.get(ref.collection, ref.id)]);
if (!entry) {
await forget(ctx, ref);
return;
}
const now = new Date();
await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString()));
}

export async function forget(ctx: PluginContext, ref: EntryRef): Promise<void> {
await ctx.storage.findings.delete(findingId(ref.collection, ref.id));
}
Loading
Loading