diff --git a/packages/content-freshness/README.md b/packages/content-freshness/README.md index 365fdcc..2adaff2 100644 --- a/packages/content-freshness/README.md +++ b/packages/content-freshness/README.md @@ -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 | @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/packages/content-freshness/emdash-plugin.jsonc b/packages/content-freshness/emdash-plugin.jsonc index 48a500c..39a285c 100644 --- a/packages/content-freshness/emdash-plugin.jsonc +++ b/packages/content-freshness/emdash-plugin.jsonc @@ -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": { diff --git a/packages/content-freshness/package.json b/packages/content-freshness/package.json index 9afd04f..e2586f6 100644 --- a/packages/content-freshness/package.json +++ b/packages/content-freshness/package.json @@ -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", diff --git a/packages/content-freshness/src/describe.ts b/packages/content-freshness/src/describe.ts new file mode 100644 index 0000000..8fc87af --- /dev/null +++ b/packages/content-freshness/src/describe.ts @@ -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}.`; + } +} diff --git a/packages/content-freshness/src/findings.ts b/packages/content-freshness/src/findings.ts new file mode 100644 index 0000000..9fd6318 --- /dev/null +++ b/packages/content-freshness/src/findings.ts @@ -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 { + if (evaluated.writes.length > 0) await ctx.storage.findings.putMany(evaluated.writes); + if (evaluated.clears.length > 0) await ctx.storage.findings.deleteMany(evaluated.clears); +} diff --git a/packages/content-freshness/src/hooks.ts b/packages/content-freshness/src/hooks.ts new file mode 100644 index 0000000..64eba5b --- /dev/null +++ b/packages/content-freshness/src/hooks.ts @@ -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 { + if (!ctx.content) return; + const [settings, entry] = await Promise.all([readSettings(ctx), ctx.content.get(ref.collection, ref.id)]); + if (!entry) { + await forget(ctx, ref); + return; + } + const now = new Date(); + await store(ctx, evaluateEntries(ref.collection, [entry], settings, now, now.toISOString())); +} + +export async function forget(ctx: PluginContext, ref: EntryRef): Promise { + await ctx.storage.findings.delete(findingId(ref.collection, ref.id)); +} diff --git a/packages/content-freshness/src/plugin.ts b/packages/content-freshness/src/plugin.ts index 74fb7c8..f9779bd 100644 --- a/packages/content-freshness/src/plugin.ts +++ b/packages/content-freshness/src/plugin.ts @@ -5,86 +5,113 @@ * `plugins: []`) and sandboxed mode. Logic lives in the sibling modules. */ +import { hasRole, ROLE } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; -import { buildReportPage, buildSettingsPage, buildWidget, countFindings } from "./report.js"; +import { changedEntry, deletedEntry, forget, reevaluate } from "./hooks.js"; import { - followUpPending, - hasRole, - isFollowUp, - listCollections, - ROLE, - scheduleFollowUp, - type SweepChain, -} from "@eisbachcode/emdash-plugin-shared"; -import { readSettings, writeSettings } from "./settings.js"; -import { auditNextPage, finishSweep } from "./sweep.js"; - -async function collectionSlugs(ctx: PluginContext): Promise { - return (await listCollections(ctx)).map((collection) => collection.slug); -} - -/** Audit the next page of the next unfinished collection. Returns whether there is more to do. */ -async function auditStep(ctx: PluginContext): Promise { - const settings = await readSettings(ctx); - const collections = await collectionSlugs(ctx); - if (collections.length === 0) { - ctx.log.warn("No collections to audit"); - return false; - } - - const result = await auditNextPage(ctx, settings, collections); - if (!result) { - await finishSweep(ctx, collections); - ctx.log.info("Freshness audit complete"); - return false; - } - ctx.log.info(`Audited ${result.entries} entries in ${result.collection}, ${result.found} findings`); - return true; -} + AUDIT_NOW_ACTION, + buildReportPage, + buildSettingsPage, + buildWidget, + countFindings, + FIRST_PAGE_ACTION, + PAGE_ACTION, + SAVE_SETTINGS_ACTION, +} from "./report.js"; +import { ensureScheduled } from "./schedule.js"; +import { applyForm, readStoredSettings, writeStoredSettings } from "./settings.js"; +import { readState } from "./state.js"; +import { AUDIT_NOW_TASK, runAudit } from "./sweep.js"; interface AdminInteraction { type: string; page?: string; action_id?: string; + value?: unknown; values?: Record; } /** - * The recurring task, and the one-shot the "Audit now" button schedules. - * - * They cannot share a name. `ctx.cron.schedule()` upserts on (plugin, task - * name), so scheduling the recurring name with a one-shot timestamp would - * replace the recurring audit with a task that runs once and never again. The - * button schedules its own name instead, and the cron handler answers to - * both. + * Settings and state, with the recurring audit registered if it was not yet. + * A failure to schedule is logged and the page still renders: the settings + * page is where a bad schedule gets fixed. */ -const AUDIT_TASK = "audit"; -const AUDIT_NOW_TASK = "audit-now"; +async function loadAdmin(ctx: PluginContext) { + const [stored, state] = await Promise.all([readStoredSettings(ctx), readState(ctx)]); + try { + if (await ensureScheduled(ctx, stored)) await writeStoredSettings(ctx, stored); + } catch (error) { + ctx.log.warn(`Could not schedule the audit: ${errorMessage(error)}`); + } + return { settings: stored.settings, state }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function cursorOf(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const cursor = (value as { cursor?: unknown }).cursor; + return typeof cursor === "string" && cursor ? cursor : undefined; +} + +/** Re-evaluate one entry after a state change. Never fails the editor's action. */ +async function refresh(ctx: PluginContext, event: unknown): Promise { + const ref = changedEntry(event); + if (!ref) return; + try { + await reevaluate(ctx, ref); + } catch (error) { + ctx.log.warn(`Could not re-check ${ref.collection}/${ref.id}: ${String(error)}`); + } +} -/** An audit carries on in follow-up runs until it is done. 500 runs is 25,000 entries at the default page size. */ -const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSteps: 500 }; +const onStateChange = { + errorPolicy: "continue" as const, + handler: async (event: unknown, ctx: PluginContext) => refresh(ctx, event), +}; const plugin = { hooks: { "plugin:activate": { handler: async (_event, ctx) => { - const settings = await readSettings(ctx); - await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); - ctx.log.info(`Freshness audit scheduled: ${settings.schedule}`); + const stored = await readStoredSettings(ctx); + await ensureScheduled(ctx, stored, true); + await writeStoredSettings(ctx, stored); }, }, + /** + * The recurring task, the one-shot the "Audit now" button schedules, + * and the follow-ups of a running sweep. + * + * The recurring task and the button's one-shot cannot share a name. + * `ctx.cron.schedule()` upserts on (plugin, task name), so scheduling + * the recurring name with a one-shot timestamp would replace the + * recurring audit with a task that runs once and never again. + */ cron: { handler: async (event, ctx) => { - const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; - if (!starts && !isFollowUp(CHAIN, event.name)) return; - // A follow-up already waiting means an audit is under way. - if (starts && (await followUpPending(ctx, CHAIN))) return; - - if (!(await auditStep(ctx))) return; - if (!(await scheduleFollowUp(ctx, CHAIN, event))) { - ctx.log.warn(`Audit paused after ${CHAIN.maxSteps} runs; it continues at the next scheduled run`); + await runAudit(ctx, event); + }, + }, + + "content:afterPublish": onStateChange, + "content:afterUnpublish": onStateChange, + "content:afterSchedule": onStateChange, + "content:afterUnschedule": onStateChange, + "content:afterRestore": onStateChange, + "content:afterDelete": { + errorPolicy: "continue", + handler: async (event, ctx) => { + const ref = deletedEntry(event); + if (!ref) return; + try { + await forget(ctx, ref); + } catch (error) { + ctx.log.warn(`Could not drop ${ref.collection}/${ref.id}: ${String(error)}`); } }, }, @@ -103,29 +130,36 @@ const plugin = { const interaction = routeCtx.input as AdminInteraction; if (interaction.type === "page_load") { - if (interaction.page === "widget:summary") return buildWidget(ctx); - if (interaction.page === "/settings") return buildSettingsPage(ctx); - return buildReportPage(ctx); + const { settings, state } = await loadAdmin(ctx); + if (interaction.page === "widget:summary") return buildWidget(ctx, state); + if (interaction.page === "/settings") return buildSettingsPage(settings); + return buildReportPage(ctx, state); } - if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { - if (!hasRole(routeCtx.user, ROLE.ADMIN)) { + if (interaction.type === "form_submit" && interaction.action_id === SAVE_SETTINGS_ACTION) { + return saveSettings(ctx, routeCtx.user, interaction.values ?? {}); + } + + if (interaction.type === "block_action") { + if (interaction.action_id === PAGE_ACTION) { + return buildReportPage(ctx, await readState(ctx), cursorOf(interaction.value)); + } + if (interaction.action_id === FIRST_PAGE_ACTION) { + return buildReportPage(ctx, await readState(ctx)); + } + if (interaction.action_id === AUDIT_NOW_ACTION) { + await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); + const state = await readState(ctx); return { - ...(await buildSettingsPage(ctx)), - toast: { message: "Only an administrator can change these settings.", type: "error" }, + ...(await buildReportPage(ctx, state)), + toast: { + message: state.sweep + ? "The running audit continues with the next scheduled run." + : "The audit starts with the next scheduled run.", + type: "info", + }, }; } - await writeSettings(ctx, interaction.values ?? {}); - const settings = await readSettings(ctx); - // Re-register so a changed cron expression takes effect. - await ctx.cron?.schedule(AUDIT_TASK, { schedule: settings.schedule }); - return buildSettingsPage(ctx); - } - - if (interaction.type === "block_action" && interaction.action_id === "audit_now") { - await finishSweep(ctx, await collectionSlugs(ctx)); - await ctx.cron?.schedule(AUDIT_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); - return buildReportPage(ctx); } return { blocks: [] }; @@ -134,16 +168,44 @@ const plugin = { status: { handler: async (_routeCtx, ctx) => { + const [state, counts] = await Promise.all([readState(ctx), countFindings(ctx)]); return { - collections: await collectionSlugs(ctx), - lastSweepFinishedAt: await ctx.kv.get("state:lastSweepFinishedAt"), - counts: await countFindings(ctx), + lastSweepFinishedAt: state.lastFinishedAt, + sweepInProgress: state.sweep !== null, + entriesBySeverity: counts, }; }, }, }, } satisfies SandboxedPlugin; +async function saveSettings( + ctx: PluginContext, + user: Parameters[0], + values: Record, +) { + const stored = await readStoredSettings(ctx); + if (!hasRole(user, ROLE.ADMIN)) { + return { + ...buildSettingsPage(stored.settings), + toast: { message: "Only an administrator can change these settings.", type: "error" as const }, + }; + } + + const next = { settings: applyForm(stored.settings, values), scheduledAs: stored.scheduledAs }; + // Schedule first: an expression the scheduler rejects is never stored. + try { + await ensureScheduled(ctx, next); + } catch (error) { + return { + ...buildSettingsPage(stored.settings), + toast: { message: `Settings not saved. ${errorMessage(error)}`, type: "error" as const }, + }; + } + await writeStoredSettings(ctx, next); + return { ...buildSettingsPage(next.settings), toast: { message: "Settings saved.", type: "success" as const } }; +} + /** * `satisfies` above for the per-hook inference (`event` typed by hook * name), and an explicit type on the export because the inferred shape diff --git a/packages/content-freshness/src/report.ts b/packages/content-freshness/src/report.ts index fa744fe..8cf8393 100644 --- a/packages/content-freshness/src/report.ts +++ b/packages/content-freshness/src/report.ts @@ -1,18 +1,42 @@ /** * Block Kit surfaces: report page, settings form, dashboard widget. + * + * Every block is typed with upstream's own interfaces, imported as types + * only so nothing of `@emdash-cms/blocks` reaches the plugin bundle. The + * host rejects a sandboxed response whose blocks do not validate, so a + * wrong key must be a compile error. */ +import type { + ActionsBlock, + BannerBlock, + Block, + BlockResponse, + FormBlock, + StatsBlock, + TableBlock, +} from "@emdash-cms/blocks"; import { listCollections } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; +import { describeHit } from "./describe.js"; +import type { EntryFindings } from "./findings.js"; +import { RANK } from "./rules.js"; import { collectionsWithoutUrlPattern } from "./routing.js"; -import { readSettings } from "./settings.js"; -import type { StoredFinding } from "./sweep.js"; +import type { Settings } from "./settings.js"; +import type { State } from "./state.js"; -type Block = Record; +export const PAGE_ACTION = "findings_page"; +export const FIRST_PAGE_ACTION = "findings_first_page"; +export const AUDIT_NOW_ACTION = "audit_now"; +export const SAVE_SETTINGS_ACTION = "save_settings"; -const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 } as const; +/** Rows per report page. Storage queries return at most 100. */ +const PAGE_ROWS = 50; +const PRIORITY = ["Urgent", "Should fix", "Nice to fix"] as const; + +/** Entries by the severity of their most urgent finding. */ export interface Counts { high: number; medium: number; @@ -21,17 +45,32 @@ export interface Counts { } export async function countFindings(ctx: PluginContext): Promise { - const [high, medium, low, total] = await Promise.all([ - ctx.storage.findings.count({ severity: "high" }), - ctx.storage.findings.count({ severity: "medium" }), - ctx.storage.findings.count({ severity: "low" }), - ctx.storage.findings.count(), - ]); - return { high, medium, low, total }; + const [high, medium, low] = await Promise.all( + [RANK.high, RANK.medium, RANK.low].map((rank) => ctx.storage.findings.count({ rank })), + ); + return { high: high ?? 0, medium: medium ?? 0, low: low ?? 0, total: (high ?? 0) + (medium ?? 0) + (low ?? 0) }; +} + +function statsBlock(counts: Counts, withTotal: boolean): StatsBlock { + return { + type: "stats", + items: [ + { label: PRIORITY[RANK.high], value: counts.high }, + { label: PRIORITY[RANK.medium], value: counts.medium }, + { label: PRIORITY[RANK.low], value: counts.low }, + ...(withTotal ? [{ label: "Entries", value: counts.total }] : []), + ], + }; +} + +function auditStatus(state: State): string { + if (state.sweep) return `Audit in progress since ${state.sweep.startedAt.slice(0, 10)}`; + if (state.lastFinishedAt) return `Last audit ${state.lastFinishedAt.slice(0, 10)}`; + return "First audit pending"; } /** A warning about collections whose public URLs EmDash gets wrong, or nothing. */ -async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { +async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { const missing = collectionsWithoutUrlPattern(await listCollections(ctx)); if (missing.length === 0) return []; const names = missing.map((collection) => collection.label || collection.slug).join(", "); @@ -49,10 +88,8 @@ async function urlPatternBanner(ctx: PluginContext, detail: boolean): Promise { - const counts = await countFindings(ctx); - const finished = await ctx.kv.get("state:lastSweepFinishedAt"); - const banner = await urlPatternBanner(ctx, false); +export async function buildWidget(ctx: PluginContext, state: State): Promise { + const [counts, banner] = await Promise.all([countFindings(ctx), urlPatternBanner(ctx, false)]); if (counts.total === 0) { if (banner.length > 0) return { blocks: banner }; @@ -60,163 +97,170 @@ export async function buildWidget(ctx: PluginContext): Promise<{ blocks: Block[] blocks: [ { type: "empty", - icon: "check", - title: finished ? "Everything current" : "First audit pending", - description: finished ? `Last audit ${finished.slice(0, 10)}.` : "Runs on the next scheduled sweep.", + title: state.lastFinishedAt ? "Everything current" : "First audit pending", + description: state.lastFinishedAt + ? `Last audit ${state.lastFinishedAt.slice(0, 10)}.` + : "Runs on the next scheduled audit.", }, ], }; } return { - blocks: [ - ...banner, - { - type: "stats", - stats: [ - { label: "Urgent", value: String(counts.high) }, - { label: "Should fix", value: String(counts.medium) }, - { label: "Nice to fix", value: String(counts.low) }, - ], - }, - { type: "context", text: finished ? `Last audit ${finished.slice(0, 10)}` : "Audit in progress" }, - ], + blocks: [...banner, statsBlock(counts, false), { type: "context", text: auditStatus(state) }], }; } -export async function buildReportPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { - const banner = await urlPatternBanner(ctx, true); - const counts = await countFindings(ctx); - const stored = await ctx.storage.findings.query({ limit: 200 }); - const rows = stored.items - .map(({ data }) => data as StoredFinding) - .sort( - (a, b) => - SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || - a.collection.localeCompare(b.collection) || - (a.slug ?? a.entryId).localeCompare(b.slug ?? b.entryId), - ); +/** + * A page of rows, most urgent first, and whether it is a later page. + * + * Storage continues from the cursor row's current rank. When that row has + * gone since the previous page, because its entry was fixed or deleted, + * nothing follows it and the page comes back empty. That, and a cursor + * storage cannot decode, fall back to the first page. + */ +async function findingsPage(ctx: PluginContext, cursor: string | undefined) { + const query = { orderBy: { rank: "asc" as const }, limit: PAGE_ROWS }; + if (cursor) { + try { + const page = await ctx.storage.findings.query({ ...query, cursor }); + if (page.items.length > 0) return { page, later: true }; + } catch { + // Undecodable cursor. + } + } + return { page: await ctx.storage.findings.query(query), later: false }; +} - const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; +export async function buildReportPage( + ctx: PluginContext, + state: State, + cursor?: string, +): Promise { + const [banner, counts, { page, later }] = await Promise.all([ + urlPatternBanner(ctx, true), + countFindings(ctx), + findingsPage(ctx, cursor), + ]); + const auditNow: ActionsBlock = { + type: "actions", + elements: [ + { type: "button", action_id: AUDIT_NOW_ACTION, label: "Audit now" }, + ...(later ? [{ type: "button" as const, action_id: FIRST_PAGE_ACTION, label: "First page" }] : []), + ], + }; - if (rows.length === 0) { + if (counts.total === 0) { return { blocks: [ { type: "header", text: "Content freshness" }, ...banner, { type: "empty", - icon: "check", title: "Nothing needs attention", description: "No stale entries, no missing descriptions, no overdue schedules.", }, + { type: "context", text: auditStatus(state) }, + auditNow, ], }; } - return { - blocks: [ - { type: "header", text: "Content freshness" }, - ...banner, + const rows = page.items.map(({ data }) => data as EntryFindings); + const multilingual = new Set(rows.map((row) => row.locale ?? null)).size > 1; + const table: TableBlock = { + type: "table", + page_action_id: PAGE_ACTION, + columns: [ + { key: "priority", label: "Priority", format: "badge" }, + { key: "entry", label: "Entry" }, + ...(multilingual ? [{ key: "locale", label: "Language" }] : []), + { key: "collection", label: "Collection" }, + { key: "findings", label: "Findings" }, + { key: "updated", label: "Last touched" }, + ], + rows: rows.map((row) => ({ + priority: PRIORITY[row.rank] ?? "", + entry: row.slug ?? row.entryId, + locale: row.locale ?? "", + collection: row.collection, + findings: row.hits.map(describeHit).join(" "), + updated: row.entryUpdatedAt.slice(0, 10), + })), + ...(page.hasMore && page.cursor ? { next_cursor: page.cursor } : {}), + }; + + const blocks: Block[] = [ + { type: "header", text: "Content freshness" }, + ...banner, + statsBlock(counts, true), + { type: "context", text: auditStatus(state) }, + table, + auditNow, + ]; + return { blocks }; +} + +export function buildSettingsPage(settings: Settings): BlockResponse { + const form: FormBlock = { + type: "form", + fields: [ + { + type: "number_input", + action_id: "staleMonths", + label: "Months before a published entry counts as stale", + min: 1, + max: 120, + initial_value: settings.staleMonths, + }, { - type: "stats", - stats: [ - { label: "Urgent", value: String(counts.high) }, - { label: "Should fix", value: String(counts.medium) }, - { label: "Nice to fix", value: String(counts.low) }, - { label: "Total", value: String(counts.total) }, - ], + type: "number_input", + action_id: "draftMonths", + label: "Months before a draft counts as forgotten", + min: 1, + max: 120, + initial_value: settings.draftMonths, }, { - type: "table", - columns: [ - { key: "severity", label: "Priority" }, - { key: "entry", label: "Entry" }, - ...(multilingual ? [{ key: "locale", label: "Language" }] : []), - { key: "collection", label: "Collection" }, - { key: "detail", label: "Finding" }, - { key: "updated", label: "Last touched" }, - ], - rows: rows.map((row) => ({ - severity: row.severity, - entry: row.slug ?? row.entryId, - locale: row.locale ?? "", - collection: row.collection, - detail: row.detail, - updated: row.entryUpdatedAt.slice(0, 10), - })), + type: "number_input", + action_id: "descriptionMin", + label: "Shortest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMin, }, { - type: "actions", - elements: [{ type: "button", action_id: "audit_now", label: "Re-run the audit now" }], + type: "number_input", + action_id: "descriptionMax", + label: "Longest acceptable SEO description", + min: 0, + max: 300, + initial_value: settings.descriptionMax, }, + { + type: "toggle", + action_id: "reportMissingDescriptions", + label: "Report entries without an SEO description", + description: "Switch off if your templates always render a description of their own.", + initial_value: settings.reportMissingDescriptions, + }, + { + type: "number_input", + action_id: "pageSize", + label: "Entries per run", + min: 1, + max: 100, + initial_value: settings.pageSize, + }, + { type: "text_input", action_id: "schedule", label: "Schedule (cron, UTC)", initial_value: settings.schedule }, ], + submit: { label: "Save", action_id: SAVE_SETTINGS_ACTION }, }; -} - -export async function buildSettingsPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { - const settings = await readSettings(ctx); return { blocks: [ { type: "header", text: "Freshness settings" }, - { - type: "context", - text: "Every collection on the site is audited.", - }, - { - type: "form", - action_id: "save_settings", - elements: [ - { - type: "number_input", - action_id: "staleMonths", - label: "Months before a published entry counts as stale", - min: 1, - max: 120, - initial_value: settings.staleMonths, - }, - { - type: "number_input", - action_id: "draftMonths", - label: "Months before a draft counts as forgotten", - min: 1, - max: 120, - initial_value: settings.draftMonths, - }, - { - type: "number_input", - action_id: "descriptionMin", - label: "Shortest acceptable SEO description", - min: 0, - max: 300, - initial_value: settings.descriptionMin, - }, - { - type: "number_input", - action_id: "descriptionMax", - label: "Longest acceptable SEO description", - min: 0, - max: 300, - initial_value: settings.descriptionMax, - }, - { - type: "toggle", - action_id: "reportMissingDescriptions", - label: "Report entries without an SEO description", - description: "Switch off if your templates always render a description of their own.", - initial_value: settings.reportMissingDescriptions, - }, - { - type: "number_input", - action_id: "pageSize", - label: "Entries per run", - min: 1, - max: 200, - initial_value: settings.pageSize, - }, - { type: "text_input", action_id: "schedule", label: "Schedule (cron)", initial_value: settings.schedule }, - ], - }, + { type: "context", text: "Every collection on the site is audited." }, + form, ], }; } diff --git a/packages/content-freshness/src/rules.ts b/packages/content-freshness/src/rules.ts index 0a82e78..167c413 100644 --- a/packages/content-freshness/src/rules.ts +++ b/packages/content-freshness/src/rules.ts @@ -4,6 +4,10 @@ * 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. + * + * 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 + * finding reads in whatever language the admin is shown in. */ import type { PluginContentItem } from "@eisbachcode/emdash-plugin-shared"; @@ -20,18 +24,17 @@ export const RULES = [ export type Rule = (typeof RULES)[number]; -export interface Finding { - collection: string; - entryId: string; - slug: string | null; - locale: string | null; +export type HitParams = Record; + +export interface Hit { rule: Rule; severity: Severity; - /** One sentence, shown verbatim in the report table. */ - detail: string; - entryUpdatedAt: string; + params: HitParams; } +/** Sort key: the lower, the more urgent. Stored so storage can order by it. */ +export const RANK: Record = { high: 0, medium: 1, low: 2 }; + export interface Thresholds { /** Published entries untouched for longer than this are stale. */ staleMonths: number; @@ -51,6 +54,13 @@ export const DEFAULT_THRESHOLDS: Thresholds = { reportMissingDescriptions: true, }; +/** + * 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 + * fires every few minutes, so an entry due a minute ago is not overdue yet. + */ +export const SCHEDULE_GRACE_MS = 60 * 60 * 1000; + /** * Field names templates commonly render as the meta description when the * SEO panel is empty. The page then still has a description, so a missing @@ -65,35 +75,21 @@ function fallbackField(data: Record): string | null { return null; } -export function evaluateEntry( - entry: PluginContentItem, - collection: string, - thresholds: Thresholds, - now: Date, -): Finding[] { - const findings: Finding[] = []; - const base = { - collection, - entryId: entry.id, - slug: entry.slug, - locale: entry.locale ?? null, - entryUpdatedAt: entry.updatedAt, - }; - +export function evaluateEntry(entry: PluginContentItem, thresholds: Thresholds, now: Date): Hit[] { + const hits: Hit[] = []; const published = entry.status === "published"; const updatedAt = parseDate(entry.updatedAt); - // A schedule that came and went without publishing. Highest severity - // because someone expected this to be live — and it is a failure mode - // this stack actually produces: with autosave the publish button does - // not re-enable, so a scheduled draft can sit there indefinitely. + // 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 + // button does not re-enable, so a scheduled draft can sit there for good. const scheduledAt = parseDate(entry.scheduledAt ?? null); - if (scheduledAt && !published && scheduledAt < now) { - findings.push({ - ...base, + if (scheduledAt && scheduledAt.getTime() < now.getTime() - SCHEDULE_GRACE_MS) { + hits.push({ rule: "overdue-schedule", severity: "high", - detail: `Scheduled for ${scheduledAt.toISOString().slice(0, 10)} but still ${entry.status}.`, + params: { date: day(scheduledAt), kind: published ? "update" : "publish", status: entry.status }, }); } @@ -104,48 +100,43 @@ export function evaluateEntry( if (!description) { if (thresholds.reportMissingDescriptions) { const fallback = fallbackField(entry.data); - findings.push({ - ...base, + hits.push({ rule: "missing-description", severity: fallback ? "low" : "medium", - detail: fallback - ? `No SEO description; templates often show the "${fallback}" field instead.` - : "Published without an SEO description.", + params: fallback ? { field: fallback } : {}, }); } } else if ( description.length < thresholds.descriptionMin || description.length > thresholds.descriptionMax ) { - findings.push({ - ...base, + hits.push({ rule: "description-length", severity: "low", - detail: `SEO description is ${description.length} characters; aim for ${thresholds.descriptionMin}–${thresholds.descriptionMax}.`, + params: { + length: description.length, + min: thresholds.descriptionMin, + max: thresholds.descriptionMax, + }, }); } } if (updatedAt) { if (published && updatedAt < subtractMonths(now, thresholds.staleMonths)) { - findings.push({ - ...base, - rule: "stale", - severity: "medium", - detail: `Not touched since ${updatedAt.toISOString().slice(0, 10)}.`, - }); + hits.push({ rule: "stale", severity: "medium", params: { since: day(updatedAt) } }); } if (entry.status === "draft" && updatedAt < subtractMonths(now, thresholds.draftMonths)) { - findings.push({ - ...base, - rule: "stale-draft", - severity: "low", - detail: `Draft untouched since ${updatedAt.toISOString().slice(0, 10)}.`, - }); + hits.push({ rule: "stale-draft", severity: "low", params: { since: day(updatedAt) } }); } } - return findings; + return hits; +} + +/** The most urgent severity among `hits`, as a rank. */ +export function worstRank(hits: Hit[]): number { + return Math.min(...hits.map((hit) => RANK[hit.severity])); } /** @@ -153,14 +144,14 @@ export function evaluateEntry( * 28 (or 29) February rather than rolling forward into March. */ export function subtractMonths(from: Date, months: number): Date { - const day = from.getUTCDate(); + const date = from.getUTCDate(); const shifted = new Date(from.getTime()); shifted.setUTCDate(1); shifted.setUTCMonth(shifted.getUTCMonth() - months); const lastDay = new Date( Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth() + 1, 0), ).getUTCDate(); - shifted.setUTCDate(Math.min(day, lastDay)); + shifted.setUTCDate(Math.min(date, lastDay)); return shifted; } @@ -171,7 +162,6 @@ export function parseDate(value: string | null | undefined): Date | null { return Number.isNaN(date.getTime()) ? null : date; } -/** Stable storage id, so a re-run overwrites instead of duplicating. */ -export function findingId(collection: string, entryId: string, rule: Rule): string { - return `${collection}:${entryId}:${rule}`; +function day(date: Date): string { + return date.toISOString().slice(0, 10); } diff --git a/packages/content-freshness/src/schedule.ts b/packages/content-freshness/src/schedule.ts new file mode 100644 index 0000000..09fb17d --- /dev/null +++ b/packages/content-freshness/src/schedule.ts @@ -0,0 +1,31 @@ +/** + * Registering the recurring audit. + */ + +import type { PluginContext } from "emdash/plugin"; + +import type { StoredSettings } from "./settings.js"; + +/** The recurring audit. `ctx.cron.schedule()` upserts on this name. */ +export const AUDIT_TASK = "audit"; + +/** + * Register the recurring audit on the stored schedule. + * + * `plugin:activate` fires only when a plugin is enabled or installed at + * runtime. A plugin listed in `astro.config.mjs` is active from the start + * and never sees it, so the admin surfaces call this too. They skip the call + * when `scheduledAs` says the task already runs on this schedule. That + * record can outlive the task, since an uninstall deletes cron tasks but + * keeps plugin KV, so activation passes `force`. + * + * Returns whether `stored` changed and needs writing. + */ +export async function ensureScheduled(ctx: PluginContext, stored: StoredSettings, force = false): Promise { + const { schedule } = stored.settings; + if (!ctx.cron || (!force && stored.scheduledAs === schedule)) return false; + await ctx.cron.schedule(AUDIT_TASK, { schedule }); + const changed = stored.scheduledAs !== schedule; + stored.scheduledAs = schedule; + return changed; +} diff --git a/packages/content-freshness/src/settings.ts b/packages/content-freshness/src/settings.ts index 64e5b46..0ecaecc 100644 --- a/packages/content-freshness/src/settings.ts +++ b/packages/content-freshness/src/settings.ts @@ -1,7 +1,13 @@ /** - * Settings in plugin KV under `settings:*`. A standard-format plugin has - * no declarative `settingsSchema` — that is native-only — so the form is - * hand-built in `report.ts`. + * Settings, stored as one object under one KV key. Every `ctx` call is a + * subrequest in the sandbox, and a key per setting cost seven of the ten a + * cron run gets. A standard-format plugin has no declarative + * `settingsSchema` — that is native-only — so the form is hand-built in + * `report.ts`. + * + * The same object records which schedule the recurring audit was last + * registered with. Only admin requests write this key, so recording it here + * never races a cron run's write of the sweep state. */ import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; @@ -9,8 +15,10 @@ import type { PluginContext } from "emdash/plugin"; import { DEFAULT_THRESHOLDS, type Thresholds } from "./rules.js"; +export const SETTINGS_KEY = "settings"; + export interface Settings extends Thresholds { - /** Entries per cron tick. */ + /** Entries per cron run. `content.list` returns at most 100. */ pageSize: number; schedule: string; } @@ -21,49 +29,64 @@ export const DEFAULT_SETTINGS: Settings = { schedule: "0 4 * * *", }; -export async function readSettings(ctx: PluginContext): Promise { - const [pageSize, schedule, staleMonths, draftMonths, descriptionMin, descriptionMax, reportMissing] = - await Promise.all([ - ctx.kv.get("settings:pageSize"), - ctx.kv.get("settings:schedule"), - ctx.kv.get("settings:staleMonths"), - ctx.kv.get("settings:draftMonths"), - ctx.kv.get("settings:descriptionMin"), - ctx.kv.get("settings:descriptionMax"), - ctx.kv.get("settings:reportMissingDescriptions"), - ]); +type NumberKey = "pageSize" | "staleMonths" | "draftMonths" | "descriptionMin" | "descriptionMax"; - const min = clampNumber(descriptionMin, 0, 300, DEFAULT_SETTINGS.descriptionMin); - const max = clampNumber(descriptionMax, 0, 300, DEFAULT_SETTINGS.descriptionMax); +const NUMBERS: Array<[NumberKey, number, number]> = [ + ["pageSize", 1, 100], + ["staleMonths", 1, 120], + ["draftMonths", 1, 120], + ["descriptionMin", 0, 300], + ["descriptionMax", 0, 300], +]; + +export interface StoredSettings { + settings: Settings; + /** The expression the recurring audit was last scheduled with. */ + scheduledAs: string | null; +} +export async function readStoredSettings(ctx: PluginContext): Promise { + const raw = await ctx.kv.get>(SETTINGS_KEY); return { - pageSize: clampNumber(pageSize, 1, 200, DEFAULT_SETTINGS.pageSize), - schedule: typeof schedule === "string" && schedule.trim() ? schedule.trim() : DEFAULT_SETTINGS.schedule, - staleMonths: clampNumber(staleMonths, 1, 120, DEFAULT_SETTINGS.staleMonths), - draftMonths: clampNumber(draftMonths, 1, 120, DEFAULT_SETTINGS.draftMonths), - descriptionMin: Math.min(min, max), - descriptionMax: Math.max(min, max), - reportMissingDescriptions: - typeof reportMissing === "boolean" ? reportMissing : DEFAULT_SETTINGS.reportMissingDescriptions, + settings: normalizeSettings(raw), + scheduledAs: typeof raw?.scheduledAs === "string" ? raw.scheduledAs : null, }; } -export async function writeSettings(ctx: PluginContext, values: Record): Promise { - if (typeof values.schedule === "string" && values.schedule.trim()) - await ctx.kv.set("settings:schedule", values.schedule.trim()); - if (typeof values.reportMissingDescriptions === "boolean") - await ctx.kv.set("settings:reportMissingDescriptions", values.reportMissingDescriptions); +export async function readSettings(ctx: PluginContext): Promise { + return (await readStoredSettings(ctx)).settings; +} - const numbers: Array<[string, number, number, number]> = [ - ["pageSize", 1, 200, DEFAULT_SETTINGS.pageSize], - ["staleMonths", 1, 120, DEFAULT_SETTINGS.staleMonths], - ["draftMonths", 1, 120, DEFAULT_SETTINGS.draftMonths], - ["descriptionMin", 0, 300, DEFAULT_SETTINGS.descriptionMin], - ["descriptionMax", 0, 300, DEFAULT_SETTINGS.descriptionMax], - ]; - for (const [key, min, max, fallback] of numbers) { - if (values[key] !== undefined) { - await ctx.kv.set(`settings:${key}`, clampNumber(values[key], min, max, fallback)); - } +export async function writeStoredSettings(ctx: PluginContext, stored: StoredSettings): Promise { + await ctx.kv.set(SETTINGS_KEY, { ...stored.settings, scheduledAs: stored.scheduledAs }); +} + +/** Settings from whatever is stored, every value clamped and defaulted. */ +export function normalizeSettings(stored: Record | null | undefined): Settings { + const source = stored ?? {}; + const settings = { ...DEFAULT_SETTINGS }; + for (const [key, min, max] of NUMBERS) { + settings[key] = clampNumber(source[key], min, max, DEFAULT_SETTINGS[key]); + } + if (typeof source.schedule === "string" && source.schedule.trim()) settings.schedule = source.schedule.trim(); + if (typeof source.reportMissingDescriptions === "boolean") { + settings.reportMissingDescriptions = source.reportMissingDescriptions; + } + const [min, max] = [settings.descriptionMin, settings.descriptionMax]; + settings.descriptionMin = Math.min(min, max); + settings.descriptionMax = Math.max(min, max); + return settings; +} + +/** `current` with the submitted form values applied. Unknown keys are ignored. */ +export function applyForm(current: Settings, values: Record): Settings { + const merged: Record = { ...current }; + for (const [key] of NUMBERS) { + if (values[key] !== undefined) merged[key] = values[key]; + } + if (typeof values.schedule === "string" && values.schedule.trim()) merged.schedule = values.schedule; + if (typeof values.reportMissingDescriptions === "boolean") { + merged.reportMissingDescriptions = values.reportMissingDescriptions; } + return normalizeSettings(merged); } diff --git a/packages/content-freshness/src/state.ts b/packages/content-freshness/src/state.ts new file mode 100644 index 0000000..47ae1d3 --- /dev/null +++ b/packages/content-freshness/src/state.ts @@ -0,0 +1,35 @@ +/** + * Where the audit stands, under one KV key: one read and at most one write + * per run, however many collections the site has. Only cron runs write it. + */ + +import type { PluginContext } from "emdash/plugin"; + +export const STATE_KEY = "state"; + +export interface Sweep { + /** 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. */ + collections: string[]; + index: number; + /** `content.list` cursor within `collections[index]`. */ + cursor: string | null; + phase: "audit" | "cleanup"; +} + +export interface State { + sweep: Sweep | null; + lastFinishedAt: string | null; +} + +const EMPTY: State = { sweep: null, lastFinishedAt: null }; + +export async function readState(ctx: PluginContext): Promise { + const stored = await ctx.kv.get>(STATE_KEY); + return { ...EMPTY, ...stored }; +} + +export async function writeState(ctx: PluginContext, state: State): Promise { + await ctx.kv.set(STATE_KEY, state); +} diff --git a/packages/content-freshness/src/sweep.ts b/packages/content-freshness/src/sweep.ts index d552ae1..10d3dd7 100644 --- a/packages/content-freshness/src/sweep.ts +++ b/packages/content-freshness/src/sweep.ts @@ -1,75 +1,141 @@ /** - * One cron tick: audit the next page of the next unfinished collection. + * The audit: a sweep over every collection, one page per cron run. * - * Cheaper than the link checker, because evaluation is local — no - * outbound request per entry. The cost per tick is one `content.list`, - * one `putMany` and one `deleteMany`, regardless of page size, which - * matters under the sandbox's 10-subrequest budget (the limit counts - * service-binding calls, so `ctx.storage` spends from it too). + * A sandboxed plugin gets ten subrequests per invocation and every `ctx` + * call is one, so a run does one bounded slice and hands over to a + * follow-up. A run costs two KV reads, one `content.list`, at most one + * `putMany` and one `deleteMany`, one KV write and one schedule, plus the + * collection list when a sweep starts. + * + * When the last collection is done, the sweep removes every row it did not + * write or confirm: entries that were trashed or deleted, and collections + * that are gone. */ -import { nextCollectionPage, resetCursors } from "@eisbachcode/emdash-plugin-shared"; +import { isFollowUp, listCollections, scheduleFollowUp, type ChainEvent, type SweepChain } from "@eisbachcode/emdash-plugin-shared"; import type { PluginContext } from "emdash/plugin"; -import { evaluateEntry, findingId, RULES, type Finding } from "./rules.js"; -import type { Settings } from "./settings.js"; +import { evaluateEntries, store } from "./findings.js"; +import { readSettings, type Settings } from "./settings.js"; +import { AUDIT_TASK } from "./schedule.js"; +import { readState, writeState, 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"; + +/** 500 runs is 50,000 entries at the largest page size. */ +export const CHAIN: SweepChain = { next: ["audit-next-a", "audit-next-b"], maxSteps: 500 }; + +/** A sweep that has not finished in this long is dropped and started over. */ +const ABANDON_AFTER_MS = 7 * 24 * 60 * 60 * 1000; -export const CURSOR_PREFIX = "state:cursor:"; +/** Most stale rows one cleanup run removes: storage queries return at most 100. */ +const CLEANUP_BATCH = 100; + +/** + * Handle a cron event. A start while a sweep is under way continues that + * sweep instead of resetting it, so a site whose sweep outlasts a day + * still finishes, and a follow-up chain that got lost resumes on the next + * scheduled run. + */ +export async function runAudit(ctx: PluginContext, event: ChainEvent): Promise { + const starts = event.name === AUDIT_TASK || event.name === AUDIT_NOW_TASK; + if (!starts && !isFollowUp(CHAIN, event.name)) return; + if (!ctx.content) return; -export interface StoredFinding extends Finding { - foundAt: string; + const [settings, state] = await Promise.all([readSettings(ctx), readState(ctx)]); + const now = new Date(); + + if (!state.sweep || abandoned(state.sweep, now)) { + // A follow-up of a sweep that already finished has nothing to do. + if (!starts) return; + state.sweep = await newSweep(ctx, now); + if (!state.sweep) return; + } + + const more = await step(ctx, settings, state, now); + await writeState(ctx, state); + if (more && !(await scheduleFollowUp(ctx, CHAIN, event))) { + ctx.log.warn(`Audit paused after ${CHAIN.maxSteps} runs; it continues at the next scheduled run`); + } } -export interface SweepResult { - collection: string; - entries: number; - found: number; - /** True when this page finished the last collection. */ - complete: boolean; +async function newSweep(ctx: PluginContext, now: Date): Promise { + const collections = (await listCollections(ctx)).map((collection) => collection.slug); + if (collections.length === 0) return null; + return { startedAt: now.toISOString(), collections, index: 0, cursor: null, phase: "audit" }; } -export async function auditNextPage( - ctx: PluginContext, - settings: Settings, - collections: string[], -): Promise { - const page = await nextCollectionPage(ctx, collections, CURSOR_PREFIX, settings.pageSize); - if (!page) return null; +function abandoned(sweep: Sweep, now: Date): boolean { + return now.getTime() - Date.parse(sweep.startedAt) > ABANDON_AFTER_MS; +} - const now = new Date(); - const writes: Array<{ id: string; data: StoredFinding }> = []; - const clears: string[] = []; - - for (const entry of page.items) { - const findings = evaluateEntry(entry, page.collection, settings, now); - const fired = new Set(findings.map((finding) => finding.rule)); - - for (const finding of findings) { - writes.push({ - id: findingId(page.collection, entry.id, finding.rule), - data: { ...finding, foundAt: now.toISOString() }, - }); - } +/** One slice of work. Returns whether the sweep needs another run. */ +async function step(ctx: PluginContext, settings: Settings, state: State, now: Date): Promise { + const sweep = state.sweep; + if (!sweep) return false; - // A rule that no longer fires had its finding fixed. Clearing the - // row here is what keeps the report from accumulating history. - for (const rule of RULES) { - if (!fired.has(rule)) clears.push(findingId(page.collection, entry.id, rule)); + if (sweep.phase === "audit") { + const collection = sweep.collections[sweep.index]; + if (collection !== undefined) { + await auditPage(ctx, settings, sweep, collection, now); + return true; } + sweep.phase = "cleanup"; + } + + const stale = await ctx.storage.findings.query({ + where: { seenIn: { lt: sweep.startedAt } }, + limit: CLEANUP_BATCH, + }); + if (stale.items.length > 0) await ctx.storage.findings.deleteMany(stale.items.map((item) => item.id)); + if (stale.hasMore) return true; + + state.sweep = null; + state.lastFinishedAt = now.toISOString(); + ctx.log.info("Freshness audit complete"); + return false; +} + +async function auditPage( + ctx: PluginContext, + settings: Settings, + sweep: Sweep, + collection: string, + now: Date, +): Promise { + const content = ctx.content; + if (!content) return; + let page; + try { + page = await content.list(collection, { + limit: settings.pageSize, + ...(sweep.cursor ? { cursor: sweep.cursor } : {}), + }); + } catch (error) { + // Only a collection deleted since the sweep started is skipped; its + // rows then fail the cleanup's `seenIn` check with the other + // leftovers. Any other failure propagates before the state is + // written, so no finding is removed for a collection that was never + // audited, and the next scheduled run retries this page. + if (await collectionExists(ctx, collection)) throw error; + ctx.log.warn(`Skipping collection ${collection}, which no longer exists`); + sweep.index += 1; + sweep.cursor = null; + return; } - if (writes.length > 0) await ctx.storage.findings.putMany(writes); - if (clears.length > 0) await ctx.storage.findings.deleteMany(clears); + await store(ctx, evaluateEntries(collection, page.items, settings, now, sweep.startedAt)); - return { - collection: page.collection, - entries: page.items.length, - found: writes.length, - complete: false, - }; + if (page.cursor) { + sweep.cursor = page.cursor; + } else { + sweep.index += 1; + sweep.cursor = null; + } } -export async function finishSweep(ctx: PluginContext, collections: string[]): Promise { - await resetCursors(ctx, collections, CURSOR_PREFIX); - await ctx.kv.set("state:lastSweepFinishedAt", new Date().toISOString()); +async function collectionExists(ctx: PluginContext, slug: string): Promise { + if (!ctx.schema) return true; + return (await ctx.schema.getCollection(slug)) !== null; } diff --git a/packages/content-freshness/tests/audit.test.ts b/packages/content-freshness/tests/audit.test.ts new file mode 100644 index 0000000..d12ad9d --- /dev/null +++ b/packages/content-freshness/tests/audit.test.ts @@ -0,0 +1,145 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { STATE_KEY } from "../src/state.js"; +import { failNextCall } from "./bridge-calls.js"; +import { finding, newHost, runSweep, undescribed } from "./host.js"; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +describe("a sweep", () => { + it("stores one row for an entry with findings, keyed by collection and entry", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "no-description"); + await runSweep(host); + + expect(await finding(host, "posts", entry.id)).toMatchObject({ + slug: "no-description", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium" }], + }); + }); + + it("removes the rows of entries it no longer finds", async () => { + host = await newHost(); + const kept = await undescribed(host, "posts", "kept"); + // A row left from an earlier sweep, for an entry deleted since. + await host.fixtures.plugin.storage("findings", "posts:01GONE", { + collection: "posts", + entryId: "01GONE", + slug: "gone", + locale: "en", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium", params: {} }], + entryUpdatedAt: "2026-01-01T00:00:00.000Z", + seenIn: "2026-01-01T00:00:00.000Z", + }); + + await runSweep(host); + + expect(await finding(host, "posts", "01GONE")).toBeNull(); + expect(await finding(host, "posts", kept.id)).not.toBeNull(); + }); + + it("does not keep a trashed entry's row", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "trashed"); + await host.actions.content.trash("posts", entry.id); + // The trash action's own hook runs deferred; let it finish, then put + // the row back as an earlier sweep would have left it. + await vi.waitFor(async () => expect(await finding(host!, "posts", entry.id)).toBeNull()); + await host.fixtures.plugin.storage("findings", `posts:${entry.id}`, { + collection: "posts", + entryId: entry.id, + slug: "trashed", + locale: "en", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium", params: {} }], + entryUpdatedAt: entry.updatedAt, + seenIn: "2026-01-01T00:00:00.000Z", + }); + + await runSweep(host); + + expect(await finding(host, "posts", entry.id)).toBeNull(); + }); +}); + +describe("a sweep that cannot list a collection", () => { + it("keeps that collection's findings when the collection still exists", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "kept-on-error"); + await runSweep(host); + + failNextCall("contentList"); + host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); + await host.transport.invokeHook("cron", { name: "audit-now" }).catch(() => undefined); + while ((await host.scheduled.run()).processed > 0); + + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + }); + + it("skips a collection deleted since the sweep started, and its rows go", async () => { + host = await newHost(); + await host.fixtures.plugin.storage("findings", "gone:01GONE", { + collection: "gone", + entryId: "01GONE", + slug: "gone", + locale: "en", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium", params: {} }], + 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" }; + await host.fixtures.plugin.kv(STATE_KEY, { sweep, 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 } }); + while ((await host.scheduled.run()).processed > 0); + + expect(await finding(host, "gone", "01GONE")).toBeNull(); + expect(await host.inspect.kv.get(STATE_KEY)).toMatchObject({ sweep: null }); + }); +}); + +describe("content hooks", () => { + it("drop an entry's row when it is deleted", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "to-delete"); + await runSweep(host); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + + await host.transport.invokeHook("content:afterDelete", { id: entry.id, collection: "posts", permanent: true }); + + expect(await finding(host, "posts", entry.id)).toBeNull(); + }); + + it("re-check an entry when its state changes", async () => { + host = await newHost(); + const entry = await undescribed(host, "posts", "to-unpublish"); + await runSweep(host); + expect(await finding(host, "posts", entry.id)).not.toBeNull(); + + // Through EmDash's own pipeline: SEO is not judged on a draft, so the + // finding goes away once the unpublish hook has re-checked the entry. + await host.actions.content.unpublish("posts", entry.id); + await vi.waitFor(async () => expect(await finding(host!, "posts", entry.id)).toBeNull()); + }); + + it("record a finding as soon as an entry is published", async () => { + host = await newHost(); + const entry = await host.fixtures.content("posts", { slug: "fresh-draft", data: {} }); + expect(await finding(host, "posts", entry.id)).toBeNull(); + + await host.actions.content.publish("posts", entry.id); + await vi.waitFor(async () => + expect(await finding(host!, "posts", entry.id)).toMatchObject({ hits: [{ rule: "missing-description" }] }), + ); + }); +}); diff --git a/packages/content-freshness/tests/bridge-calls.ts b/packages/content-freshness/tests/bridge-calls.ts new file mode 100644 index 0000000..850b1e7 --- /dev/null +++ b/packages/content-freshness/tests/bridge-calls.ts @@ -0,0 +1,55 @@ +import { PluginBridge } from "@emdash-cms/plugin-test/worker"; + +/** + * The bridge calls a sandboxed invocation makes. + * + * On Cloudflare every `ctx` call crosses the bridge as one subrequest, and + * EmDash gives a sandboxed invocation ten; the eleventh aborts it. The + * local harness enforces no limit, so this wraps the class those calls + * land on, the harness's own `PluginBridge`. workerd constructs a fresh + * entrypoint instance for every incoming RPC call, so the first method + * that runs on an instance is the call; later ones on the same instance + * are the bridge using its own helpers. + */ + +const calls: string[] = []; +const seen = new WeakSet(); +const failing = new Set(); +let installed = false; + +function install(): void { + if (installed) return; + installed = true; + const proto = PluginBridge.prototype as unknown as Record; + for (const name of Object.getOwnPropertyNames(proto)) { + const original = Object.getOwnPropertyDescriptor(proto, name)?.value; + if (name === "constructor" || typeof original !== "function") continue; + proto[name] = function (this: object, ...args: unknown[]) { + if (!seen.has(this)) { + seen.add(this); + calls.push(name); + if (failing.delete(name)) return Promise.reject(new Error(`${name} failed (test)`)); + } + return (original as (...a: unknown[]) => unknown).apply(this, args); + }; + } +} + +/** Run one invocation and return the bridge calls it made, in order. */ +export async function bridgeCalls(invocation: () => Promise): Promise { + install(); + calls.length = 0; + await invocation(); + return [...calls]; +} + +/** + * Make the next call to a bridge method fail, as a lost subrequest would. + * + * The harness's RPC wrapper also prints the rejection as an uncaught + * exception. The plugin still receives it; the test run is unaffected. + */ +export function failNextCall(method: string): void { + install(); + failing.add(method); +} diff --git a/packages/content-freshness/tests/budget.test.ts b/packages/content-freshness/tests/budget.test.ts new file mode 100644 index 0000000..b94c1d9 --- /dev/null +++ b/packages/content-freshness/tests/budget.test.ts @@ -0,0 +1,174 @@ +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AUDIT_NOW_ACTION, PAGE_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { SETTINGS_KEY } from "../src/settings.js"; +import { STATE_KEY, type State } from "../src/state.js"; +import { bridgeCalls } from "./bridge-calls.js"; +import { finding, newHost, undescribed } from "./host.js"; + +/** + * Every invocation, at its worst case, against EmDash's sandbox limit. + * + * A sandboxed invocation may make ten subrequests and each `ctx` call, + * `ctx.log` included, is one; the eleventh throws "Too many subrequests" on + * Cloudflare. The harness does not enforce the limit, so each test counts + * the calls and also checks the invocation did its work, so a fixture too + * small to reach the worst case fails instead of passing quietly. + */ + +const LIMIT = 10; + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +async function state(runtime: PluginRuntimeTestHost) { + return runtime.inspect.kv.get(STATE_KEY); +} + +/** + * Four entries, two per page, created flagged and clean in turn: whichever + * way `content.list` orders them, every page holds one entry with a finding + * and one without, so every audit run both writes and clears. + */ +async function twoPageSite() { + const runtime = await newHost(); + await runtime.fixtures.plugin.kv(SETTINGS_KEY, { pageSize: 2 }); + const flagged = []; + const clean = []; + for (const n of [1, 2]) { + flagged.push(await undescribed(runtime, "posts", `flagged-${n}`)); + clean.push(await runtime.fixtures.content("posts", { slug: `clean-${n}`, data: {}, status: "draft" })); + } + return { runtime, flagged, clean }; +} + +function staleRow(entryId: string) { + return { + collection: "posts", + entryId, + slug: entryId, + locale: "en", + rank: 2, + hits: [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], + entryUpdatedAt: "2025-01-01T00:00:00.000Z", + seenIn: "2026-01-01T00:00:00.000Z", + }; +} + +describe("cron runs", () => { + it("the run that starts a sweep", async () => { + const { runtime } = await twoPageSite(); + host = runtime; + + const calls = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit" })); + + expect(calls).toEqual(expect.arrayContaining(["schemaListCollections", "storagePutMany", "storageDeleteMany"])); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + }); + + it("a follow-up that writes and clears", async () => { + const { runtime, flagged, clean } = await twoPageSite(); + host = runtime; + // Leftover rows for the clean entries, which the audit must clear. + for (const entry of clean) { + await runtime.fixtures.plugin.storage("findings", `posts:${entry.id}`, staleRow(entry.id)); + } + await runtime.transport.invokeHook("cron", { name: "audit" }); + + const calls = await bridgeCalls(() => + runtime.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } }), + ); + + expect(calls).toEqual(expect.arrayContaining(["contentList", "storagePutMany", "storageDeleteMany"])); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + // Both pages done: every clean entry cleared, every flagged one stored. + for (const entry of clean) expect(await finding(runtime, "posts", entry.id)).toBeNull(); + for (const entry of flagged) expect(await finding(runtime, "posts", entry.id)).not.toBeNull(); + }); + + it("a cleanup run with more stale rows than one batch, and the run that finishes", async () => { + host = await newHost(); + const runtime = host; + for (let i = 0; i < 101; i++) { + await runtime.fixtures.plugin.storage("findings", `posts:OLD${i}`, staleRow(`OLD${i}`)); + } + const sweep = { + startedAt: new Date().toISOString(), + collections: ["posts"], + index: 1, + cursor: null, + phase: "cleanup", + }; + await runtime.fixtures.plugin.kv(STATE_KEY, { sweep, lastFinishedAt: null, scheduledAs: null }); + + const batch = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit-next-a", data: { step: 1 } })); + expect(batch).toContain("storageDeleteMany"); + expect((await state(runtime))?.sweep).not.toBeNull(); + expect(batch.length).toBeLessThanOrEqual(LIMIT); + + const last = await bridgeCalls(() => runtime.transport.invokeHook("cron", { name: "audit-next-b", data: { step: 2 } })); + expect((await state(runtime))?.sweep).toBeNull(); + expect(last.length).toBeLessThanOrEqual(LIMIT); + }); +}); + +describe("content hooks", () => { + it("a state change that writes a finding", async () => { + host = await newHost(); + const runtime = host; + const entry = await undescribed(runtime, "posts", "published-now"); + + const calls = await bridgeCalls(() => + runtime.transport.invokeHook("content:afterPublish", { content: { id: entry.id }, collection: "posts" }), + ); + + expect(await finding(runtime, "posts", entry.id)).not.toBeNull(); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + }); +}); + +describe("admin requests", () => { + async function siteWithFindings() { + const runtime = await newHost(); + // A routable collection without a URL pattern, so the banner's lookup runs too. + await runtime.fixtures.collection({ slug: "pages", label: "Pages", routable: true }); + await undescribed(runtime, "posts", "flagged"); + await runtime.transport.invokeHook("cron", { name: "audit" }); + return runtime; + } + + it("the first dashboard view, which also schedules the audit", async () => { + host = await siteWithFindings(); + const runtime = host; + const calls = await bridgeCalls(() => runtime.admin.loadWidget("summary")); + expect(calls).toContain("cronSchedule"); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + }); + + it("the first report view, which also schedules the audit", async () => { + host = await siteWithFindings(); + const runtime = host; + const calls = await bridgeCalls(() => runtime.admin.loadPage("/report")); + expect(calls).toContain("cronSchedule"); + expect(calls).toContain("storageQuery"); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + }); + + it("paging, Audit now and saving the settings", async () => { + host = await siteWithFindings(); + const runtime = host; + for (const invocation of [ + () => runtime.admin.act("/report", PAGE_ACTION, { value: { cursor: "x" } }), + () => runtime.admin.act("/report", AUDIT_NOW_ACTION), + () => runtime.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "15 3 * * *", staleMonths: 6 }), + ]) { + const calls = await bridgeCalls(invocation); + expect(calls.length).toBeLessThanOrEqual(LIMIT); + } + }); +}); diff --git a/packages/content-freshness/tests/cron-names.test.ts b/packages/content-freshness/tests/cron-names.test.ts index 97e9e1a..a9c38f5 100644 --- a/packages/content-freshness/tests/cron-names.test.ts +++ b/packages/content-freshness/tests/cron-names.test.ts @@ -11,7 +11,7 @@ import plugin from "../src/plugin.js"; * handler has to answer to both. */ -/** Minimal context: `readSettings` reads plugin KV and nothing else. */ +/** Minimal context: settings and state come from KV, and the site has no collections, so no sweep starts. */ function fakeContext() { const warns: string[] = []; const reads: string[] = []; @@ -22,6 +22,8 @@ function fakeContext() { return null; }, }, + content: {}, + schema: { listCollections: async () => [] }, log: { debug() {}, info() {}, diff --git a/packages/content-freshness/tests/full-audit.test.ts b/packages/content-freshness/tests/full-audit.test.ts index c50b4e8..baf9e2a 100644 --- a/packages/content-freshness/tests/full-audit.test.ts +++ b/packages/content-freshness/tests/full-audit.test.ts @@ -1,6 +1,8 @@ import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; import { afterEach, describe, expect, it } from "vitest"; +import { STATE_KEY, type State } from "../src/state.js"; + /** * One scheduled run audits one page of one collection. A nightly schedule * that ran one page per night would need a night per collection, so a run @@ -39,6 +41,6 @@ describe("a scheduled audit", () => { } expect(runs).toBeLessThan(20); - expect(await host.inspect.kv.get("state:lastSweepFinishedAt")).not.toBeNull(); + expect(await host.inspect.kv.get(STATE_KEY)).toMatchObject({ sweep: null, lastFinishedAt: expect.any(String) }); }); }); diff --git a/packages/content-freshness/tests/host.ts b/packages/content-freshness/tests/host.ts new file mode 100644 index 0000000..334faa3 --- /dev/null +++ b/packages/content-freshness/tests/host.ts @@ -0,0 +1,47 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; + +import type { EntryFindings } from "../src/findings.js"; + +/** + * Hosts for tests that run the plugin sandboxed in workerd. + * + * Two properties of this harness shape the tests: + * + * - `actions.content.*` fire the plugin's hooks deferred through `after()`, + * and nothing awaits them. Hooks are therefore driven with + * `transport.invokeHook()`, and the one test of the real pipeline waits + * for its effect. + * - Plugin storage outlives `dispose()` within a test file, so assertions + * name the rows they are about instead of counting the collection. + */ + +/** A host with one SEO-enabled collection, so a published entry without a description has a finding. */ +export async function newHost(slug = "posts"): Promise { + const host = await createPluginRuntimeTestHost(); + await host.fixtures.collection({ slug, label: slug, hasSeo: true, routable: true, urlPattern: `/${slug}/{slug}` }); + return host; +} + +/** A published entry with no SEO description: one medium finding. */ +export async function undescribed(host: PluginRuntimeTestHost, collection: string, slug: string) { + return host.fixtures.content(collection, { slug, data: {}, status: "published" }); +} + +/** + * Start an audit and run the real cron executor until nothing is due. + * + * Follow-ups are scheduled from the wall clock, so the host's scheduler + * clock is moved a day past it; every one-shot is then due at once. + */ +export async function runSweep(host: PluginRuntimeTestHost): Promise { + host.scheduled.setTime(new Date(Date.now() + 24 * 60 * 60 * 1000)); + await host.transport.invokeHook("cron", { name: "audit-now" }); + for (let runs = 0; runs < 50; runs++) { + if ((await host.scheduled.run()).processed === 0) return; + } + throw new Error("the sweep did not finish in 50 runs"); +} + +export async function finding(host: PluginRuntimeTestHost, collection: string, entryId: string) { + return host.inspect.storage.get("findings", `${collection}:${entryId}`); +} diff --git a/packages/content-freshness/tests/permissions.test.ts b/packages/content-freshness/tests/permissions.test.ts index aac9afe..4aef9ff 100644 --- a/packages/content-freshness/tests/permissions.test.ts +++ b/packages/content-freshness/tests/permissions.test.ts @@ -82,6 +82,6 @@ describe("saving settings", () => { expect(writes.length).toBeGreaterThan(0); // The cron is re-registered so a changed expression takes effect. expect(scheduled).toContain("audit"); - expect(result.toast).toBeUndefined(); + expect(result.toast?.type).toBe("success"); }); }); diff --git a/packages/content-freshness/tests/plugin.test.ts b/packages/content-freshness/tests/plugin.test.ts index 6ab9ced..abfb1dd 100644 --- a/packages/content-freshness/tests/plugin.test.ts +++ b/packages/content-freshness/tests/plugin.test.ts @@ -9,7 +9,16 @@ import plugin from "../src/plugin.js"; */ describe("plugin surface", () => { it("registers the audit hooks", () => { - expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual(["cron", "plugin:activate"]); + expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual([ + "content:afterDelete", + "content:afterPublish", + "content:afterRestore", + "content:afterSchedule", + "content:afterUnpublish", + "content:afterUnschedule", + "cron", + "plugin:activate", + ]); }); it("registers the admin and status routes", () => { diff --git a/packages/content-freshness/tests/report-paging.test.ts b/packages/content-freshness/tests/report-paging.test.ts new file mode 100644 index 0000000..ab5c5f6 --- /dev/null +++ b/packages/content-freshness/tests/report-paging.test.ts @@ -0,0 +1,77 @@ +import type { StatsBlock, TableBlock } from "@emdash-cms/blocks"; +import { validateBlocks } from "@emdash-cms/blocks/server"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, expect, it } from "vitest"; + +import type { EntryFindings } from "../src/findings.js"; +import { PAGE_ACTION } from "../src/report.js"; + +/** + * Plugin storage returns at most 100 rows per query. The report used to ask + * for 200 and sort what came back, so on a site with more than 100 findings + * an urgent one could be missing from the table while the stats counted it. + * Alone in this file because plugin storage outlives a host within a file. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +function row(entryId: string, rank: number): EntryFindings { + return { + collection: "posts", + entryId, + slug: entryId.toLowerCase(), + locale: "en", + rank, + hits: + rank === 0 + ? [{ rule: "overdue-schedule", severity: "high", params: { date: "2026-09-01", kind: "publish", status: "draft" } }] + : [{ rule: "stale-draft", severity: "low", params: { since: "2025-01-01" } }], + entryUpdatedAt: "2025-01-01T00:00:00.000Z", + seenIn: "2026-09-25T04:00:00.000Z", + }; +} + +it("opens with the most urgent entries however many findings there are", async () => { + host = await createPluginRuntimeTestHost(); + for (let i = 0; i < 100; i++) { + const id = `LOW${String(i).padStart(3, "0")}`; + await host.fixtures.plugin.storage("findings", `posts:${id}`, row(id, 2)); + } + await host.fixtures.plugin.storage("findings", "posts:ZURGENT", row("ZURGENT", 0)); + + const first = await host.admin.loadPage("/report"); + expect(validateBlocks(first.blocks).valid).toBe(true); + const stats = first.blocks.find((block): block is StatsBlock => block.type === "stats"); + expect(stats?.items.map((item) => item.value)).toEqual([1, 0, 100, 101]); + const table = first.blocks.find((block): block is TableBlock => block.type === "table"); + expect(table?.rows[0]).toMatchObject({ entry: "zurgent", priority: "Urgent" }); + expect(table?.next_cursor).toBeTruthy(); + + const second = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); + const next = second.blocks.find((block): block is TableBlock => block.type === "table"); + expect(next?.rows.length).toBeGreaterThan(0); + expect(next?.rows.map((r) => r.entry)).not.toContain("zurgent"); +}); + +it("falls back to the first page when the row the cursor points at is gone", async () => { + host = await createPluginRuntimeTestHost(); + for (let i = 0; i < 60; i++) { + const id = `CUR${String(i).padStart(3, "0")}`; + await host.fixtures.plugin.storage("findings", `posts:${id}`, row(id, 1)); + } + const first = await host.admin.loadPage("/report"); + const table = first.blocks.find((block): block is TableBlock => block.type === "table"); + const last = table?.rows.at(-1)?.entry as string; + // Its entry was fixed or deleted before "Load more". + await host.transport.invokeHook("content:afterDelete", { id: last.toUpperCase(), collection: "posts", permanent: true }); + + const next = await host.admin.act("/report", PAGE_ACTION, { value: { cursor: table?.next_cursor } }); + + const rows = next.blocks.find((block): block is TableBlock => block.type === "table")?.rows ?? []; + expect(rows.length).toBeGreaterThan(0); +}); diff --git a/packages/content-freshness/tests/report.test.ts b/packages/content-freshness/tests/report.test.ts index c57c141..71d6f7b 100644 --- a/packages/content-freshness/tests/report.test.ts +++ b/packages/content-freshness/tests/report.test.ts @@ -1,67 +1,79 @@ +import type { Block, FormBlock, TableBlock } from "@emdash-cms/blocks"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; +import type { EntryFindings } from "../src/findings.js"; import { buildReportPage, buildSettingsPage } from "../src/report.js"; -import type { StoredFinding } from "../src/sweep.js"; +import { DEFAULT_SETTINGS } from "../src/settings.js"; +import type { State } from "../src/state.js"; -function finding(slug: string, locale: string): StoredFinding { +const STATE: State = { sweep: null, lastFinishedAt: "2026-09-24T04:10:00.000Z" }; + +function row(slug: string, locale: string): EntryFindings { return { collection: "pages", entryId: `01${slug.toUpperCase()}`, slug, locale, - rule: "missing-description", - severity: "medium", - detail: "Published without an SEO description.", + rank: 1, + hits: [{ rule: "missing-description", severity: "medium", params: {} }], entryUpdatedAt: "2026-09-17T00:00:00.000Z", - foundAt: "2026-09-24T00:00:00.000Z", + seenIn: "2026-09-24T04:00:00.000Z", }; } -function fakeContext(findings: StoredFinding[], kv: Record = {}) { +function fakeContext(rows: EntryFindings[]) { return { - kv: { get: async (key: string) => kv[key] ?? null }, schema: { listCollections: async () => [] }, storage: { findings: { - count: async () => findings.length, - query: async () => ({ items: findings.map((data) => ({ id: data.entryId, data })), cursor: null }), + count: async (where?: { rank?: number }) => rows.filter((r) => r.rank === where?.rank).length, + query: async () => ({ + items: rows.map((data) => ({ id: `${data.collection}:${data.entryId}`, data })), + hasMore: false, + }), }, }, } as unknown as PluginContext; } -function table(blocks: Array>) { - return blocks.find((block) => block.type === "table") as - | { columns: Array<{ key: string }>; rows: Array> } - | undefined; +function table(blocks: Block[]) { + return blocks.find((block): block is TableBlock => block.type === "table"); } describe("the findings table", () => { it("tells 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([finding("about", "de"), finding("about", "en")])); - const rows = table(blocks)?.rows ?? []; - expect(rows.map((row) => row.locale).sort()).toEqual(["de", "en"]); + const { blocks } = await buildReportPage(fakeContext([row("about", "de"), row("about", "en")]), STATE); + expect(table(blocks)?.rows.map((r) => r.locale).sort()).toEqual(["de", "en"]); expect(table(blocks)?.columns.map((column) => column.key)).toContain("locale"); }); it("leaves the language out on a single-language site", async () => { - const { blocks } = await buildReportPage(fakeContext([finding("about", "en"), finding("contact", "en")])); + const { blocks } = await buildReportPage(fakeContext([row("about", "en"), row("contact", "en")]), STATE); expect(table(blocks)?.columns.map((column) => column.key)).not.toContain("locale"); }); + + it("names the priority and every finding of an entry in one row", async () => { + const entry = row("about", "en"); + entry.hits.push({ rule: "stale", severity: "medium", params: { since: "2024-05-01" } }); + const { blocks } = await buildReportPage(fakeContext([entry]), STATE); + const [first] = table(blocks)?.rows ?? []; + expect(first?.priority).toBe("Should fix"); + expect(first?.findings).toContain("SEO description"); + expect(first?.findings).toContain("2024-05-01"); + }); }); describe("the settings form", () => { - it("shows the saved values, not blank fields", async () => { + it("shows the saved values, not blank fields", () => { // Block Kit inputs render `initial_value`; anything else leaves the field empty. - const { blocks } = await buildSettingsPage( - fakeContext([], { "settings:staleMonths": 18, "settings:reportMissingDescriptions": false }), - ); - const form = blocks.find((block) => block.type === "form") as { - elements: Array<{ action_id: string; initial_value?: unknown }>; + const { blocks } = buildSettingsPage({ ...DEFAULT_SETTINGS, staleMonths: 18, reportMissingDescriptions: false }); + const form = blocks.find((block): block is FormBlock => block.type === "form"); + const value = (id: string) => { + const field = form?.fields.find((f) => "action_id" in f && f.action_id === id); + return field && "initial_value" in field ? field.initial_value : undefined; }; - const value = (id: string) => form.elements.find((element) => element.action_id === id)?.initial_value; expect(value("staleMonths")).toBe(18); expect(value("reportMissingDescriptions")).toBe(false); expect(value("schedule")).toBe("0 4 * * *"); diff --git a/packages/content-freshness/tests/routing.test.ts b/packages/content-freshness/tests/routing.test.ts index 94ea5b1..12e18ef 100644 --- a/packages/content-freshness/tests/routing.test.ts +++ b/packages/content-freshness/tests/routing.test.ts @@ -1,7 +1,9 @@ +import type { BannerBlock, Block } from "@emdash-cms/blocks"; import type { PluginContext } from "emdash/plugin"; import { describe, expect, it } from "vitest"; import { buildReportPage, buildWidget } from "../src/report.js"; +import type { State } from "../src/state.js"; /** * A routable collection without a URL pattern gets `/{collection}/{slug}` @@ -12,21 +14,22 @@ import { buildReportPage, buildWidget } from "../src/report.js"; type Collection = { slug: string; label: string; routable: boolean; urlPattern: string | null }; +const STATE: State = { sweep: null, lastFinishedAt: null }; + function fakeContext(collections: Collection[]) { return { - kv: { get: async () => null }, schema: { listCollections: async () => collections }, storage: { findings: { count: async () => 0, - query: async () => ({ items: [], cursor: null }), + query: async () => ({ items: [], hasMore: false }), }, }, } as unknown as PluginContext; } -function banners(blocks: Array>) { - return blocks.filter((block) => block.type === "banner"); +function banners(blocks: Block[]) { + return blocks.filter((block): block is BannerBlock => block.type === "banner"); } describe("the URL pattern warning", () => { @@ -37,7 +40,7 @@ describe("the URL pattern warning", () => { { slug: "authors", label: "Authors", routable: false, urlPattern: null }, ]); - const { blocks } = await buildReportPage(ctx); + const { blocks } = await buildReportPage(ctx, STATE); const [banner] = banners(blocks); expect(banner?.title).toContain("Posts"); expect(banner?.title).not.toContain("Pages"); @@ -46,13 +49,13 @@ describe("the URL pattern warning", () => { it("reaches the dashboard even when no entry has a finding", async () => { const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: null }]); - const { blocks } = await buildWidget(ctx); + const { blocks } = await buildWidget(ctx, STATE); expect(banners(blocks)).toHaveLength(1); }); it("stays out of the way when every routable collection has a pattern", async () => { const ctx = fakeContext([{ slug: "posts", label: "Posts", routable: true, urlPattern: "/blog/{slug}" }]); - expect(banners((await buildReportPage(ctx)).blocks)).toHaveLength(0); - expect(banners((await buildWidget(ctx)).blocks)).toHaveLength(0); + expect(banners((await buildReportPage(ctx, STATE)).blocks)).toHaveLength(0); + expect(banners((await buildWidget(ctx, STATE)).blocks)).toHaveLength(0); }); }); diff --git a/packages/content-freshness/tests/rules.test.ts b/packages/content-freshness/tests/rules.test.ts index 0d399d7..3f426aa 100644 --- a/packages/content-freshness/tests/rules.test.ts +++ b/packages/content-freshness/tests/rules.test.ts @@ -20,8 +20,7 @@ function entry(overrides: Partial = {}): PluginContentItem { } as PluginContentItem; } -const rules = (item: PluginContentItem) => - evaluateEntry(item, "posts", DEFAULT_THRESHOLDS, NOW).map((finding) => finding.rule); +const rules = (item: PluginContentItem) => evaluateEntry(item, DEFAULT_THRESHOLDS, NOW).map((hit) => hit.rule); describe("evaluateEntry", () => { it("finds nothing wrong with a fresh, described entry", () => { @@ -41,24 +40,32 @@ describe("evaluateEntry", () => { }); it("flags a schedule that passed without publishing, at high severity", () => { - const findings = evaluateEntry( + const hits = evaluateEntry( entry({ status: "draft", scheduledAt: "2026-08-01T00:00:00.000Z" }), - "posts", DEFAULT_THRESHOLDS, NOW, ); - const overdue = findings.find((finding) => finding.rule === "overdue-schedule"); - expect(overdue?.severity).toBe("high"); - expect(overdue?.detail).toContain("2026-08-01"); + const overdue = hits.find((hit) => hit.rule === "overdue-schedule"); + expect(overdue).toMatchObject({ severity: "high", params: { date: "2026-08-01", kind: "publish" } }); }); - it("ignores a schedule in the future, and one that already published", () => { - expect(rules(entry({ status: "draft", scheduledAt: "2027-01-01T00:00:00.000Z" }))).not.toContain( - "overdue-schedule", + it("flags scheduled changes to a published entry that never went live", () => { + // EmDash keeps `scheduledAt` on a published entry for scheduled draft + // changes and clears it once they publish, so a past one was missed. + const [hit] = evaluateEntry( + entry({ status: "published", scheduledAt: "2026-08-01T00:00:00.000Z" }), + DEFAULT_THRESHOLDS, + NOW, ); - expect(rules(entry({ status: "published", scheduledAt: "2026-08-01T00:00:00.000Z" }))).not.toContain( + expect(hit).toMatchObject({ rule: "overdue-schedule", params: { kind: "update" } }); + }); + + it("ignores a schedule in the future, and one the scheduler has not had time to run", () => { + expect(rules(entry({ status: "draft", scheduledAt: "2027-01-01T00:00:00.000Z" }))).not.toContain( "overdue-schedule", ); + const minutesAgo = new Date(NOW.getTime() - 10 * 60 * 1000).toISOString(); + expect(rules(entry({ status: "draft", scheduledAt: minutesAgo }))).not.toContain("overdue-schedule"); }); it("says nothing about SEO for a collection that has none", () => { @@ -80,25 +87,28 @@ describe("evaluateEntry", () => { it("drops to low priority and names the field templates tend to use", () => { // Field names from a real site whose templates fall back to them. for (const data of [{ excerpt: "96 Prozent …" }, { hero_subheadline: "IT-Beratung …" }]) { - const [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); - expect(finding).toMatchObject({ rule: "missing-description", severity: "low" }); - expect(finding?.detail).toContain(Object.keys(data)[0]!); + const [hit] = evaluateEntry(entry({ seo: noSeo, data }), DEFAULT_THRESHOLDS, NOW); + expect(hit).toMatchObject({ + rule: "missing-description", + severity: "low", + params: { field: Object.keys(data)[0] }, + }); } }); it("stays a real finding when the fallback is empty or not a fallback at all", () => { for (const data of [{ excerpt: " " }, { hero_body: "Text" }, {}]) { - const [finding] = evaluateEntry(entry({ seo: noSeo, data }), "posts", DEFAULT_THRESHOLDS, NOW); - expect(finding?.severity).toBe("medium"); + const [hit] = evaluateEntry(entry({ seo: noSeo, data }), DEFAULT_THRESHOLDS, NOW); + expect(hit?.severity).toBe("medium"); } }); it("is not reported at all once the site switches the check off", () => { const off = { ...DEFAULT_THRESHOLDS, reportMissingDescriptions: false }; - expect(evaluateEntry(entry({ seo: noSeo }), "posts", off, NOW)).toEqual([]); + expect(evaluateEntry(entry({ seo: noSeo }), off, NOW)).toEqual([]); // The length check is separate and still applies. const short = { title: null, description: "too short", image: null, canonical: null, noIndex: false }; - expect(evaluateEntry(entry({ seo: short }), "posts", off, NOW).map((f) => f.rule)).toEqual([ + expect(evaluateEntry(entry({ seo: short }), off, NOW).map((hit) => hit.rule)).toEqual([ "description-length", ]); }); diff --git a/packages/content-freshness/tests/settings.test.ts b/packages/content-freshness/tests/settings.test.ts index 13137a1..f5661e9 100644 --- a/packages/content-freshness/tests/settings.test.ts +++ b/packages/content-freshness/tests/settings.test.ts @@ -1,38 +1,33 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SETTINGS, readSettings, writeSettings } from "../src/settings.js"; +import { applyForm, DEFAULT_SETTINGS, normalizeSettings } from "../src/settings.js"; -describe("readSettings", () => { - it("returns defaults on an empty store", async () => { - expect(await readSettings(fakeKv({}))).toEqual(DEFAULT_SETTINGS); +describe("normalizeSettings", () => { + it("returns defaults for nothing stored", () => { + expect(normalizeSettings(null)).toEqual(DEFAULT_SETTINGS); }); - it("orders the description bounds even if they are stored the wrong way round", async () => { - const settings = await readSettings( - fakeKv({ "settings:descriptionMin": 200, "settings:descriptionMax": 40 }), - ); + it("orders the description bounds even if they are stored the wrong way round", () => { + const settings = normalizeSettings({ descriptionMin: 200, descriptionMax: 40 }); expect([settings.descriptionMin, settings.descriptionMax]).toEqual([40, 200]); }); -}); -describe("writeSettings", () => { - it("clamps numbers and ignores unknown keys", async () => { - const store: Record = {}; - await writeSettings(fakeKv(store), { staleMonths: 9999, nonsense: true }); - expect(store["settings:staleMonths"]).toBe(120); - expect(store).not.toHaveProperty("nonsense"); + it("caps the page size at what content.list returns", () => { + // content.list clamps its limit at 100; a larger setting would promise + // work a run never does. + expect(normalizeSettings({ pageSize: 200 }).pageSize).toBe(100); }); }); -function fakeKv(store: Record) { - return { - kv: { - get: async (key: string) => store[key] ?? null, - set: async (key: string, value: unknown) => { - store[key] = value; - }, - delete: async (key: string) => delete store[key], - list: async () => [], - }, - } as unknown as import("emdash/plugin").PluginContext; -} +describe("applyForm", () => { + it("clamps numbers and ignores unknown keys", () => { + const next = applyForm(DEFAULT_SETTINGS, { staleMonths: 9999, nonsense: true }); + expect(next.staleMonths).toBe(120); + expect(next).not.toHaveProperty("nonsense"); + }); + + it("keeps values the form did not send", () => { + const current = { ...DEFAULT_SETTINGS, draftMonths: 3 }; + expect(applyForm(current, { staleMonths: 18 })).toMatchObject({ staleMonths: 18, draftMonths: 3 }); + }); +}); diff --git a/packages/content-freshness/tests/surfaces.test.ts b/packages/content-freshness/tests/surfaces.test.ts new file mode 100644 index 0000000..da66ba1 --- /dev/null +++ b/packages/content-freshness/tests/surfaces.test.ts @@ -0,0 +1,114 @@ +import { validateBlocks } from "@emdash-cms/blocks/server"; +import type { PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { AUDIT_NOW_ACTION, SAVE_SETTINGS_ACTION } from "../src/report.js"; +import { SETTINGS_KEY } from "../src/settings.js"; +import { failNextCall } from "./bridge-calls.js"; +import { newHost, runSweep, undescribed } from "./host.js"; + +/** + * The host rejects a sandboxed response whose blocks do not validate with a + * 502, which is what the report, the widget and the settings form all did + * before their blocks were typed: `stats` needs `items`, a table needs + * `page_action_id`, a form needs `fields` and `submit`. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +const valid = (response: { blocks: unknown[] }) => validateBlocks(response.blocks as never).valid; + +async function auditTask(runtime: PluginRuntimeTestHost) { + return (await runtime.inspect.scheduledTasks()).find((task) => task.name === "audit"); +} + +describe("every admin surface validates", () => { + it("before the first audit", async () => { + host = await newHost(); + expect(valid(await host.admin.loadWidget("summary"))).toBe(true); + expect(valid(await host.admin.loadPage("/report"))).toBe(true); + expect(valid(await host.admin.loadPage("/settings"))).toBe(true); + }); + + it("with findings to show", async () => { + host = await newHost(); + await undescribed(host, "posts", "no-description"); + await runSweep(host); + expect(valid(await host.admin.loadWidget("summary"))).toBe(true); + expect(valid(await host.admin.loadPage("/report"))).toBe(true); + expect(valid(await host.admin.act("/report", AUDIT_NOW_ACTION))).toBe(true); + }); +}); + +describe("the recurring audit", () => { + it("is scheduled the first time the admin shows the plugin, without an activate event", async () => { + // A plugin listed in astro.config.mjs is active from the start and + // never receives plugin:activate. + host = await newHost(); + expect(await auditTask(host)).toBeUndefined(); + + await host.admin.loadWidget("summary"); + + expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); + }); + + it("keeps its schedule when the settings name one the scheduler rejects", async () => { + host = await newHost(); + await host.admin.loadPage("/settings"); + + const response = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "every night at four" }); + + expect(response.toast?.type).toBe("error"); + expect(response.toast?.message).toContain("every night at four"); + expect(await host.inspect.kv.get(SETTINGS_KEY)).toMatchObject({ schedule: "0 4 * * *" }); + expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); + }); + + it("is registered again on activation even when the settings say it already runs", async () => { + // An uninstall that keeps the data deletes the plugin's cron tasks but + // not its KV, so on reinstall the record says "scheduled" and is wrong. + host = await newHost(); + await host.fixtures.plugin.kv(SETTINGS_KEY, { scheduledAs: "0 4 * * *" }); + + await host.actions.plugin.activate(); + + expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); + }); + + it("does not take the dashboard down when scheduling fails", async () => { + host = await newHost(); + failNextCall("cronSchedule"); + + expect(valid(await host.admin.loadWidget("summary"))).toBe(true); + expect(await auditTask(host)).toBeUndefined(); + + await host.admin.loadWidget("summary"); + expect(await auditTask(host)).toMatchObject({ schedule: "0 4 * * *" }); + }); + + it("moves to a new schedule that the scheduler accepts", async () => { + host = await newHost(); + const response = await host.admin.submit("/settings", SAVE_SETTINGS_ACTION, { schedule: "30 2 * * *" }); + + expect(response.toast?.type).toBe("success"); + expect(await auditTask(host)).toMatchObject({ schedule: "30 2 * * *" }); + }); +}); + +describe("the report's paging", () => { + it("falls back to the first page for a cursor storage cannot read", async () => { + host = await newHost(); + await undescribed(host, "posts", "no-description"); + await runSweep(host); + + const response = await host.admin.act("/report", "findings_page", { value: { cursor: "not-a-cursor" } }); + + expect(valid(response)).toBe(true); + expect(JSON.stringify(response.blocks)).toContain("no-description"); + }); +}); diff --git a/packages/content-freshness/tests/sweep.test.ts b/packages/content-freshness/tests/sweep.test.ts deleted file mode 100644 index b0e3a95..0000000 --- a/packages/content-freshness/tests/sweep.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_SETTINGS, type Settings } from "../src/settings.js"; -import { auditNextPage } from "../src/sweep.js"; - -const SETTINGS: Settings = { ...DEFAULT_SETTINGS, pageSize: 10 }; -const COLLECTIONS = ["posts"]; - -describe("auditNextPage", () => { - it("writes findings and clears the rules that no longer fire, in one call each", async () => { - const writes: Array<{ id: string }> = []; - const clears: string[] = []; - let putCalls = 0; - let deleteCalls = 0; - - const ctx = fakeCtx( - [{ items: [staleEntry()], cursor: null }], - { - putMany: async (items) => { - putCalls += 1; - writes.push(...items); - }, - deleteMany: async (ids) => { - deleteCalls += 1; - clears.push(...ids); - return ids.length; - }, - }, - ); - - const result = await auditNextPage(ctx, SETTINGS, COLLECTIONS); - expect(result).toMatchObject({ collection: "posts", entries: 1, found: 1 }); - expect(writes.map((item) => item.id)).toEqual(["posts:01STALE:stale"]); - // The other four rules are cleared so a fixed finding disappears. - expect(clears).toHaveLength(4); - // Batched: one write call and one delete call for the whole page. - expect([putCalls, deleteCalls]).toEqual([1, 1]); - }); - - it("returns null when every collection is walked", async () => { - const ctx = fakeCtx([], {}); - // The fake serves an empty last page, so the cursor lands on "done". - await auditNextPage(ctx, SETTINGS, COLLECTIONS); - expect(await auditNextPage(ctx, SETTINGS, COLLECTIONS)).toBeNull(); - }); -}); - -function staleEntry() { - return { - id: "01STALE", - type: "posts", - slug: "old-news", - status: "published", - locale: "en", - data: {}, - createdAt: "2020-01-01T00:00:00.000Z", - updatedAt: "2020-01-01T00:00:00.000Z", - publishedAt: "2020-01-01T00:00:00.000Z", - }; -} - -function fakeCtx( - pages: Array<{ items: ReturnType[]; cursor: string | null }>, - storage: { - putMany?: (items: Array<{ id: string; data: unknown }>) => Promise; - deleteMany?: (ids: string[]) => Promise; - }, -) { - const store: Record = {}; - let served = 0; - return { - kv: { - get: async (key: string) => store[key] ?? null, - set: async (key: string, value: unknown) => { - store[key] = value; - }, - delete: async (key: string) => delete store[key], - list: async () => [], - }, - content: { - list: async () => pages[served++] ?? { items: [], cursor: null }, - get: async () => null, - }, - storage: { - findings: { - putMany: storage.putMany ?? (async () => {}), - deleteMany: storage.deleteMany ?? (async () => 0), - count: async () => 0, - query: async () => ({ items: [], cursor: null }), - }, - }, - } as unknown as import("emdash/plugin").PluginContext; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24fb327..304b5b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,13 +19,13 @@ importers: version: link:../shared '@emdash-cms/blocks': specifier: 0.39.0 - version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@emdash-cms/plugin-cli': specifier: 0.12.0 version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@emdash-cms/plugin-test': specifier: 0.2.0 - version: 0.2.0(f863f6b7fe5271f41d62dece9033c2f3) + version: 0.2.0(23e33028ac8753110cfb4cf3c9afb42f) emdash: specifier: '>=0.39.0 <1.0.0' version: 0.39.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) @@ -47,12 +47,15 @@ importers: '@eisbachcode/emdash-plugin-shared': specifier: workspace:* version: link:../shared + '@emdash-cms/blocks': + specifier: 0.39.0 + version: 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@emdash-cms/plugin-cli': specifier: 0.12.0 - version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + version: 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@emdash-cms/plugin-test': specifier: 0.2.3 - version: 0.2.3(f64bdd591a184e04878ebed7d065ed68) + version: 0.2.3(824e54279456c15d4fb280fbef81e1d3) emdash: specifier: '>=0.39.0 <1.0.0' version: 0.40.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) @@ -67,7 +70,7 @@ importers: devDependencies: emdash: specifier: '>=0.39.0 <1.0.0' - version: 0.40.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) + version: 0.40.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -4665,6 +4668,28 @@ snapshots: - '@types/react' - date-fns + '@cloudflare/kumo@2.6.0(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': + dependencies: + '@base-ui/react': 1.8.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@shikijs/langs': 4.4.3 + '@shikijs/themes': 4.4.3 + clsx: 2.1.1 + motion: 12.43.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + shiki: 4.4.3 + tailwind-merge: 3.7.0 + optionalDependencies: + echarts: 6.1.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20260921.1)': @@ -4987,6 +5012,84 @@ snapshots: - typescript - zod + '@emdash-cms/admin@0.40.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4)': + dependencies: + '@atcute/lexicons': 2.1.1 + '@cloudflare/kumo': 2.6.0(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@dnd-kit/core': 6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@dnd-kit/sortable': 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react@19.3.0) + '@dnd-kit/utilities': 3.2.2(react@19.3.0) + '@emdash-cms/blocks': 0.40.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@lingui/core': 5.9.5 + '@lingui/react': 5.9.5(react@19.3.0) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/react-query': 5.90.21(react@19.3.0) + '@tanstack/react-router': 1.163.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-character-count': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-collaboration': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33) + '@tiptap/extension-drag-handle': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)) + '@tiptap/extension-drag-handle-react': 3.31.3(@tiptap/extension-drag-handle@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-collaboration@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33))(yjs@13.6.33))(@tiptap/extension-node-range@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@tiptap/y-tiptap@3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33)))(@tiptap/pm@3.31.3)(@tiptap/react@3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/extension-dropcursor': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-node-range': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-subscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-superscript': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-table-cell': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-header': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-table-row': 3.31.3(@tiptap/extension-table@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/y-tiptap': 3.0.9(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(y-protocols@1.0.7(yjs@13.6.33))(yjs@13.6.33) + class-variance-authority: 0.7.1 + clsx: 2.1.1 + dompurify: 3.4.16 + highlight.js: 11.11.2 + lowlight: 3.3.0 + marked: 17.0.6 + modern-tar: 0.7.7 + react: 19.3.0 + react-day-picker: 9.14.0(react@19.3.0) + react-dom: 19.3.0(react@19.3.0) + react-hotkeys-hook: 5.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react-image-crop: 11.1.2(react@19.3.0) + tailwind-merge: 3.7.0 + y-protocols: 1.0.7(yjs@13.6.33) + yjs: 13.6.33 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - date-fns + - echarts + - prosemirror-model + - prosemirror-state + - prosemirror-view + - typescript + - zod + '@emdash-cms/auth@0.39.0(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6)': dependencies: '@oslojs/crypto': 1.0.1 @@ -5036,6 +5139,22 @@ snapshots: - date-fns - zod + '@emdash-cms/blocks@0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + dependencies: + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + clsx: 2.1.1 + echarts: 6.1.0 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + tailwind-merge: 3.7.0 + transitivePeerDependencies: + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@types/react' + - date-fns + - zod + '@emdash-cms/blocks@0.39.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': dependencies: '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) @@ -5068,9 +5187,9 @@ snapshots: - date-fns - zod - '@emdash-cms/blocks@0.40.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + '@emdash-cms/blocks@0.40.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4)': dependencies: - '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + '@cloudflare/kumo': 2.6.0(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) clsx: 2.1.1 echarts: 6.1.0 @@ -5084,7 +5203,7 @@ snapshots: - date-fns - zod - '@emdash-cms/cloudflare@0.39.0(a7ee94a070d1e2ec5745e252646edfd0)': + '@emdash-cms/cloudflare@0.39.0(4393bffb26115eecd490f095bbbbcb1e)': dependencies: '@astrojs/cloudflare': 14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1) '@cloudflare/ai-search-snippet': 0.0.42 @@ -5097,7 +5216,7 @@ snapshots: ulidx: 2.4.1 wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) optionalDependencies: - '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: 8.23.0 react: 19.3.0 @@ -5128,7 +5247,7 @@ snapshots: - typescript - utf-8-validate - '@emdash-cms/cloudflare@0.40.1(4393bffb26115eecd490f095bbbbcb1e)': + '@emdash-cms/cloudflare@0.40.1(a7ee94a070d1e2ec5745e252646edfd0)': dependencies: '@astrojs/cloudflare': 14.3.3(@types/node@26.6.2)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(esbuild@0.28.2)(wrangler@4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2))(yaml@2.9.1) '@cloudflare/ai-search-snippet': 0.0.42 @@ -5141,7 +5260,7 @@ snapshots: ulidx: 2.4.1 wrangler: 4.138.0(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2) optionalDependencies: - '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + '@cloudflare/kumo': 2.6.0(@date-fns/tz@1.5.0)(@phosphor-icons/react@2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(@types/react@19.3.0)(date-fns@4.4.0)(echarts@6.1.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) '@phosphor-icons/react': 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) pg: 8.23.0 react: 19.3.0 @@ -5187,6 +5306,45 @@ snapshots: '@wordpress/block-serialization-default-parser': 5.56.0 parse5: 7.3.0 + '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@atcute/oauth-node-client': 2.0.1(@atcute/identity-resolver@2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3))(@atcute/lexicons@2.1.1)(typescript@6.0.3) + '@clack/prompts': 1.8.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@6.0.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@oslojs/crypto': 1.0.1 + chokidar: 5.0.0 + citty: 0.2.2 + consola: 3.4.2 + jsonc-parser: 3.3.1 + modern-tar: 0.7.7 + picocolors: 1.1.1 + tsdown: 0.20.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(typescript@6.0.3) + typescript: 6.0.3 + zod: 4.5.4 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - '@vitejs/devtools' + - oxc-resolver + - publint + - synckit + - unplugin-lightningcss + - unplugin-unused + - vue-tsc + '@emdash-cms/plugin-cli@0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@6.0.3) @@ -5226,11 +5384,11 @@ snapshots: - unplugin-unused - vue-tsc - '@emdash-cms/plugin-test@0.2.0(f863f6b7fe5271f41d62dece9033c2f3)': + '@emdash-cms/plugin-test@0.2.0(23e33028ac8753110cfb4cf3c9afb42f)': dependencies: '@cloudflare/vitest-plugin': 1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))) - '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) - '@emdash-cms/cloudflare': 0.39.0(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/blocks': 0.39.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) + '@emdash-cms/cloudflare': 0.39.0(4393bffb26115eecd490f095bbbbcb1e) '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@emdash-cms/plugin-types': 0.4.0 emdash: 0.39.0(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) @@ -5288,12 +5446,12 @@ snapshots: - wrangler - zod - '@emdash-cms/plugin-test@0.2.3(f64bdd591a184e04878ebed7d065ed68)': + '@emdash-cms/plugin-test@0.2.3(824e54279456c15d4fb280fbef81e1d3)': dependencies: '@cloudflare/vitest-plugin': 1.2.5(@cloudflare/workers-types@5.20260924.1)(@types/node@26.6.2)(@vitest/runner@4.1.11)(@vitest/snapshot@4.1.11)(vitest@4.1.11(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1))) - '@emdash-cms/blocks': 0.40.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) - '@emdash-cms/cloudflare': 0.40.1(4393bffb26115eecd490f095bbbbcb1e) - '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@emdash-cms/blocks': 0.40.1(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/cloudflare': 0.40.1(a7ee94a070d1e2ec5745e252646edfd0) + '@emdash-cms/plugin-cli': 0.12.0(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) '@emdash-cms/plugin-types': 0.4.0 emdash: 0.40.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@date-fns/tz@1.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(date-fns@4.4.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3) kysely: 0.29.6 @@ -6110,6 +6268,14 @@ snapshots: '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) @@ -7250,6 +7416,94 @@ snapshots: - typescript - utf-8-validate + emdash@0.40.1(@astrojs/react@7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1))(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@10.2.2)(typescript@5.9.3): + dependencies: + '@astrojs/react': 7.0.0(@types/node@26.6.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(esbuild@0.28.2)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(yaml@2.9.1) + '@atcute/client': 5.1.2(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/identity-resolver': 2.0.2(@atcute/identity@2.0.2(@atcute/lexicons@2.1.1)(typescript@5.9.3))(@atcute/lexicons@2.1.1)(typescript@5.9.3) + '@atcute/lexicons': 2.1.1 + '@atcute/multibase': 1.2.5 + '@emdash-cms/admin': 0.40.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(@floating-ui/dom@1.8.0)(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(echarts@6.1.0)(prosemirror-model@1.25.12)(prosemirror-state@1.4.4)(prosemirror-view@1.42.5)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3)(zod@4.5.4) + '@emdash-cms/auth': 0.40.1(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1))(kysely@0.29.6) + '@emdash-cms/blocks': 0.40.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.5.4) + '@emdash-cms/gutenberg-to-portable-text': 0.40.1 + '@emdash-cms/plugin-types': 0.4.0 + '@emdash-cms/registry-client': 0.6.1(@atcute/cbor@2.3.8(@atcute/cid@2.5.0))(@atcute/cid@2.5.0)(typescript@5.9.3) + '@emdash-cms/registry-lexicons': 0.6.0 + '@emdash-cms/registry-verification': 0.3.2 + '@floating-ui/react': 0.27.20(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@modelcontextprotocol/sdk': 1.30.1(supports-color@10.2.2)(zod@4.5.4) + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@portabletext/toolkit': 5.0.2 + '@tiptap/core': 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-code': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-code-block-lowlight': 3.20.0(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/extension-code-block@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(highlight.js@11.11.2)(lowlight@3.3.0) + '@tiptap/extension-focus': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-image': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-link': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-placeholder': 3.31.3(@tiptap/extensions@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) + '@tiptap/extension-text-align': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-typography': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-underline': 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/pm': 3.31.3 + '@tiptap/react': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tiptap/starter-kit': 3.31.3 + '@tiptap/suggestion': 3.31.3(@floating-ui/dom@1.8.0)(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@unpic/placeholder': 0.1.2 + arctic: 3.7.0 + astro: 7.3.5(@types/node@26.6.2)(yaml@2.9.1) + astro-portabletext: 0.11.4(astro@7.3.5(@types/node@26.6.2)(yaml@2.9.1)) + blurhash: 2.0.5 + citty: 0.2.2 + consola: 3.4.2 + croner: 10.0.1 + highlight.js: 11.11.2 + jose: 6.2.12 + jpeg-js: 0.4.4 + jsonc-parser: 3.3.1 + kysely: 0.29.6 + lowlight: 3.3.0 + mime: 4.1.0 + modern-tar: 0.7.7 + picocolors: 1.1.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + sanitize-html: 2.17.7 + sax: 1.6.1 + smol-toml: 1.9.0 + ulidx: 2.4.1 + upng-js: 2.1.0 + zod: 4.5.4 + optionalDependencies: + '@libsql/kysely-libsql': 0.4.1(kysely@0.29.6) + pg: 8.23.0 + transitivePeerDependencies: + - '@atcute/cbor' + - '@atcute/cid' + - '@atcute/identity' + - '@cfworker/json-schema' + - '@date-fns/tz' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@tiptap/extensions' + - '@types/react' + - '@types/react-dom' + - babel-plugin-macros + - bufferutil + - date-fns + - echarts + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + - utf-8-validate + empathic@2.1.0: {} encodeurl@2.0.0: {} @@ -8134,6 +8388,23 @@ snapshots: nlcst-to-string: 4.0.0 unist-util-visit: 5.1.0 + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(typescript@6.0.3): + dependencies: + '@babel/generator': 8.0.0-rc.2 + '@babel/helper-validator-identifier': 8.0.0-rc.2 + '@babel/parser': 8.0.0-rc.2 + '@babel/types': 8.0.0-rc.2 + ast-kit: 3.0.0 + birpc: 4.2.0 + dts-resolver: 2.1.3 + get-tsconfig: 4.14.3 + obug: 2.2.1 + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@5.9.3): dependencies: '@babel/generator': 8.0.0-rc.2 @@ -8151,7 +8422,7 @@ snapshots: transitivePeerDependencies: - oxc-resolver - rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3): + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 @@ -8189,6 +8460,28 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.17 '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.17 + rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): + dependencies: + '@oxc-project/types': 0.112.0 + '@rolldown/pluginutils': 1.0.0-rc.3 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.3 + '@rolldown/binding-darwin-x64': 1.0.0-rc.3 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.3 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.3 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.3 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.3 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3): dependencies: '@oxc-project/types': 0.112.0 @@ -8459,6 +8752,35 @@ snapshots: trough@2.2.0: {} + tsdown@0.20.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(typescript@6.0.3): + dependencies: + ansis: 4.4.0 + cac: 6.7.14 + defu: 6.1.7 + empathic: 2.1.0 + hookable: 6.1.2 + import-without-cache: 0.2.5 + obug: 2.2.1 + picomatch: 4.0.7 + rolldown: 1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1))(typescript@6.0.3) + semver: 7.8.5 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + unrun: 0.2.39 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@ts-macro/tsc' + - '@typescript/native-preview' + - oxc-resolver + - synckit + - vue-tsc + tsdown@0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@5.9.3): dependencies: ansis: 4.4.0 @@ -8499,7 +8821,7 @@ snapshots: obug: 2.2.1 picomatch: 4.0.7 rolldown: 1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) - rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@6.0.3) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17