diff --git a/README.md b/README.md index 51d2324..ef3f67c 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Plugins for [EmDash](https://github.com/emdash-cms/emdash) by [Eisbachcode](http |---|---| | [`@eisbachcode/emdash-plugin-analytics`](packages/analytics) | Cloudflare Web Analytics on the EmDash dashboard and next to your content: traffic per entry, a setup check, German and English | | [`@eisbachcode/emdash-plugin-content-freshness`](packages/content-freshness) | A scheduled audit of your content: stale entries, missing SEO descriptions, forgotten drafts, schedules that never fired. Read-only, no network | +| [`@eisbachcode/emdash-plugin-content-link-check`](packages/content-link-check) | A scheduled check of the links in your content: broken links, dead domains and redirects, reported only once they persist. Read-only | ## Development diff --git a/packages/content-link-check/.agents/skills b/packages/content-link-check/.agents/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/content-link-check/.agents/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/content-link-check/.claude/CLAUDE.md b/packages/content-link-check/.claude/CLAUDE.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/packages/content-link-check/.claude/CLAUDE.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/packages/content-link-check/.claude/skills b/packages/content-link-check/.claude/skills new file mode 120000 index 0000000..42c5394 --- /dev/null +++ b/packages/content-link-check/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/packages/content-link-check/.gitignore b/packages/content-link-check/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/packages/content-link-check/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/content-link-check/AGENTS.md b/packages/content-link-check/AGENTS.md new file mode 100644 index 0000000..e63c328 --- /dev/null +++ b/packages/content-link-check/AGENTS.md @@ -0,0 +1,14 @@ +# Agent instructions + +Before editing this plugin, read `skills/creating-plugins/SKILL.md` completely. Codex discovers the same directory through `.agents/skills`; Claude discovers it through `.claude/skills` and reads these instructions through `.claude/CLAUDE.md`. +Keep `emdash-plugin.jsonc` aligned with the runtime implementation, declare every capability and host the plugin uses, and run the generated validation, typecheck, test, and build scripts after changes. + +## Toolchain + +`emdash` is a peer with a floor and no ceiling (`>=0.39.0`, the same as +`env:emdash` in the manifest); the dev dependency stays below the next +major until the toolchain is moved on purpose. Built with +`@emdash-cms/plugin-cli@0.12.0` and `@emdash-cms/plugin-test@0.2.3` (EmDash 0.40.1); +`scripts/compat-matrix.sh` at the repository root runs the suite against +later EmDash releases. A plain `pnpm install` at the workspace root is +enough. diff --git a/packages/content-link-check/README.md b/packages/content-link-check/README.md new file mode 100644 index 0000000..5ab1505 --- /dev/null +++ b/packages/content-link-check/README.md @@ -0,0 +1,123 @@ +# content-link-check + +Scheduled link audit for [EmDash](https://emdashcms.com) content. Finds broken +links, dead domains, redirects and malformed URLs in your entries, and reports +them in the admin. + +- Sweeps every collection on a cron schedule in small steps, one per firing + of the site's cron trigger, until the sweep is done. Each step stays + within the 10 calls a sandboxed plugin gets per run, and in-process + within Workers Free's 50 subrequests and 50 D1 queries: it reads a page of + entries and writes at most 20 rows, or tidies up, or checks about four + links. A sweep of a few hundred links finishes overnight on a per-minute + cron trigger; a slower trigger takes proportionally longer. A sweep still + running at the next scheduled start carries on rather than starting over. +- Checks internal and outbound links in published entries. Internal links are + requested from the live site rather than resolved from the database, so + hand-built Astro pages count as well as CMS entries. +- Checks each distinct link once per sweep, however many entries link to it. + The report lists it once, with the first entry it appears in and how many + more there are, and shows up to 100 findings at a time. +- Keeps the report in step with the content. Every sweep records what it + finds and then drops whatever it did not find again, so a link taken out + of an entry, or an entry unpublished or deleted, leaves the report when + the next sweep finishes. +- Reports a redirect hop on an internal link instead of following it, because + that is something EmDash's redirect engine can fix. Outbound links are + followed and judged by where they end. +- Only a clear failure counts: a 404 or 410, a domain that no longer resolves, + or a server error that persists. A 401, 403, 429 or any other answer that + does not show whether the link works is never reported: the link's last + clear result stands, and a link never clearly checked is counted as + unresolved. A site that blocks datacentre IPs therefore does not fill the + report with noise. A host that answers 429 is not asked again, for any + link, before its `Retry-After`. +- A failure has to repeat and to last before it is reported: 12 hours for a + 404 or a dead domain, 60 hours for a server error, so running two sweeps in a + row cannot report an unreachable link. A URL that does not parse is reported + on the repeat alone, since waiting cannot fix it. Once reported, a link stays + in the report until it works again. +- Reports only. It has no `content:write` capability and never edits an entry. + +It does not look for orphaned entries. Navigation usually lives in menus and +templates, which a plugin cannot read, so almost every page would look +unlinked. + +## Trust contract + +| Capability | Why | +| --- | --- | +| `content:read` | Read entries to find the links in them. | +| `schema:read` | List the collections to sweep. | +| `network:request:unrestricted` | The hosts checked are whatever your editors linked to, so no fixed allow-list can be pinned at publish time. Only `HEAD` and `GET` are issued, no content is sent, and no response body is read. | + +## Install + +Config-based, on any EmDash site and any plan: + +```js +// astro.config.mjs +import contentLinkCheck from "@eisbachcode/emdash-plugin-content-link-check"; + +emdash({ plugins: [contentLinkCheck] }); +``` + +Or under `sandboxed: []` if the site has a sandbox runner configured. Both +work the same way; see the sweep above for how each run stays within a +sandboxed plugin's allowance. + +There is no activation step. A plugin listed in the Astro config is never +"activated" the way one switched on under **Admin → Extensions** is, so the +plugin registers its nightly sweep itself. That happens the first time +someone opens the admin dashboard, the link report or its settings, or saves +or publishes an entry. + +**The site needs a cron trigger.** On Cloudflare that is `"crons"` in +`wrangler.jsonc` plus `scheduled: createScheduledHandler()` on the Worker +entry. Without one, the sweep is scheduled and never runs. The sweep advances +one step per firing, so a per-minute trigger (`"* * * * *"`) is the one to +use. + +## Settings + +| Setting | Default | | +| --- | --- | --- | +| Entries per step | 25 | Entries read per step of a sweep, up to 100. A page that links to more targets than a step may write is read in smaller pieces. It does not change how many links a step checks. | +| Schedule | `0 3 * * *` | Any cron expression. | +| Check outbound links | on | Off restricts the sweep to internal links. | +| Failures before reporting | 2 | Failed checks in a row before a link counts as broken, on top of the 12 or 60 hours above. | + +## Develop + +```sh +pnpm install +pnpm test +pnpm typecheck +pnpm build +``` + +`pnpm dev` rebuilds on save. To try it against a local site, add the built +package with a `file:` dependency and register it in `plugins: []`. + +## Compatibility + +| EmDash | Plugin test suite | +| ------ | ----------------- | +| 0.40.1 | 89 passed | +| 0.40.0 | 89 passed | +| 0.39.1 | 89 passed | +| 0.39.0 | 89 passed | + +Declared range: `emdash >=0.39.0 <1.0.0`. The table covers **every released +version inside it**, 0.39.0 through 0.40.1, so this is the whole range rather +than a sample. 0.39.0 is the floor because the plugin lists collections through +`schema:read`, which earlier versions do not have. + +Reproduce with `scripts/compat-matrix.sh 0.39.0 0.39.1 0.40.0 0.40.1` from the +repo root. Last run 2026-09-26, when 0.40.1 was the latest EmDash release. A newer EmDash than the +table's top row is untested rather than unsupported: nothing blocks the install, +and this table is how you tell the difference. + +## Licence + +MIT © Eisbachcode diff --git a/packages/content-link-check/emdash-plugin.jsonc b/packages/content-link-check/emdash-plugin.jsonc new file mode 100644 index 0000000..5342374 --- /dev/null +++ b/packages/content-link-check/emdash-plugin.jsonc @@ -0,0 +1,54 @@ +{ + "$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json", + + "slug": "content-link-check", + "publisher": "did:plc:iucwrgdd464ddkranaa6ux3l", + + "license": "MIT", + "author": { "name": "Eisbachcode", "url": "https://eisbachcode.de" }, + "security": { "email": "daniel@eisbachcode.de" }, + "repo": "https://github.com/eisbachcode/emdash-plugins", + + // The only version check a user meets before installing. EmDash refuses + // an install whose host falls outside this range, so it carries a floor + // and deliberately no ceiling: EmDash ships a minor every 6-8 days, and + // a ceiling would block correct installs every week rather than catch a + // break. The manifest has no advisory "tested up to" field to express + // caution without blocking, so the tested range belongs in the README. + "release": { + "requires": { "env:emdash": ">=0.39.0" } + }, + "description": "Checks internal and outbound links in your content on a schedule and reports broken links, dead domains, and redirects in the admin.", + "keywords": ["links", "seo", "audit", "maintenance"], + + // Trust contract. + // + // `network:request:unrestricted` is unavoidable here: the hosts this + // plugin contacts are whatever editors linked to, so there is no fixed + // allow-list that could be pinned at publish time. Same reasoning as + // upstream's own `webhook-notifier`. The plugin only ever issues HEAD + // and GET requests, never sends content, and reads no response body. + // + // `content:read` covers the sweep over entries, `schema:read` lists the + // collections to sweep. There is deliberately no `content:write` — this + // plugin reports, it never edits. + "capabilities": ["content:read", "schema:read", "network:request:unrestricted"], + "allowedHosts": [], + + "storage": { + // One row per distinct link target: its last check, its verdict and + // the places that link to it. `finding` is the state of a reported + // target and null otherwise, so the report and the widget can query + // it. `checkedAt` finds the targets still due in a sweep, `seenAt` + // the ones nothing links to any more. + "checks": { "indexes": ["finding", "state", "checkedAt", "seenAt"] }, + }, + + "admin": { + "pages": [ + { "path": "/report", "label": "Link report", "icon": "link" }, + { "path": "/settings", "label": "Link check settings", "icon": "settings" }, + ], + "widgets": [{ "id": "summary", "title": "Broken links", "size": "third" }], + }, +} diff --git a/packages/content-link-check/package.json b/packages/content-link-check/package.json new file mode 100644 index 0000000..0b5485b --- /dev/null +++ b/packages/content-link-check/package.json @@ -0,0 +1,67 @@ +{ + "name": "@eisbachcode/emdash-plugin-content-link-check", + "version": "0.1.0", + "private": true, + "description": "EmDash plugin: scheduled link check for your content — broken links, dead domains and redirects.", + "keywords": [ + "emdash", + "emdash-plugin", + "cms", + "plugin", + "links", + "link-checker", + "seo", + "audit" + ], + "license": "MIT", + "author": "Eisbachcode ", + "homepage": "https://github.com/eisbachcode/emdash-plugins/tree/main/packages/content-link-check#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/eisbachcode/emdash-plugins.git", + "directory": "packages/content-link-check" + }, + "bugs": "https://github.com/eisbachcode/emdash-plugins/issues", + "type": "module", + "main": "dist/index.mjs", + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/plugin.mjs" + }, + "files": [ + "dist", + "emdash-plugin.jsonc" + ], + "scripts": { + "validate": "emdash-plugin validate", + "build": "emdash-plugin build", + "dev": "emdash-plugin dev", + "typecheck": "tsc --noEmit", + "test": "emdash-plugin validate && vitest run", + "bundle": "emdash-plugin bundle", + "registry:login": "emdash-plugin login", + "registry:publish": "emdash-plugin publish", + "release:setup": "emdash-plugin release setup", + "prepublishOnly": "pnpm run build" + }, + "engines": { + "node": ">=22.16" + }, + "peerDependencies": { + "emdash": ">=0.39.0" + }, + "devDependencies": { + "@eisbachcode/emdash-plugin-shared": "workspace:*", + "@emdash-cms/plugin-cli": "0.12.0", + "@emdash-cms/plugin-test": "0.2.3", + "emdash": ">=0.39.0 <1.0.0", + "typescript": "^5.9.0", + "vitest": "^4.1.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/content-link-check/skills/creating-plugins/SKILL.md b/packages/content-link-check/skills/creating-plugins/SKILL.md new file mode 100644 index 0000000..336829d --- /dev/null +++ b/packages/content-link-check/skills/creating-plugins/SKILL.md @@ -0,0 +1,29 @@ +--- +name: creating-plugins +description: Build, test, and publish this sandboxed EmDash plugin. Use for changes to emdash-plugin.jsonc, src/plugin.ts, hooks, routes, capabilities, storage, Block Kit admin UI, bundling, or releases. +--- + +# Creating EmDash plugins + +Read `emdash-plugin.jsonc` and `src/plugin.ts` before editing. The manifest is the identity and trust contract; the source contains runtime hooks and routes. + +## Runtime rules + +- Assign the runtime definition to a `SandboxedPlugin`-typed constant and export it as default from `src/plugin.ts`. +- Use Web APIs. Do not import Node.js built-ins into plugin runtime code. +- Declare every runtime API in `capabilities` and every network destination in `allowedHosts`. +- Use `ctx.storage` for queryable records and `ctx.kv` for key-value state. +- Use Block Kit for sandboxed admin UI. Do not ship browser React components. +- Treat public routes as internet-facing and validate their inputs. + +## Validation + +Use the package scripts in this repository. The test script builds the plugin and runs it inside workerd through EmDash's production sandbox wrapper and host bridge. Use `createPluginTestHost()` to invoke hooks and routes, create content fixtures, and inspect plugin KV or declared storage. Dispose the host after each test so its bindings reset. + +Before handing off a change, run validation, typecheck, tests, and build. A release also requires a version bump in `package.json` when runtime behavior or the trust contract changes. + +## Publishing + +Use the local publish script for a release started from this computer. Use the release-setup script for GitHub Actions. Setup detects a root Changesets configuration and offers to follow packages released by Changesets; otherwise it uses package tags. Connect the generated reusable workflow to the existing Changesets publish job by passing its published-package output. Changesets Action v1 names the step output `publishedPackages`; v2 names it `published-packages`. Expose it as a `published-packages` job output and pass it to the generated workflow from a dependent job when Changesets reports `published == 'true'`. The first automated release connects the repository workflow; later packages reuse it only when their signed profiles name the same repository. + +For complete EmDash patterns and API details, use https://docs.emdashcms.com/plugins/creating-plugins/. diff --git a/packages/content-link-check/src/budget.ts b/packages/content-link-check/src/budget.ts new file mode 100644 index 0000000..b239d94 --- /dev/null +++ b/packages/content-link-check/src/budget.ts @@ -0,0 +1,62 @@ +/** + * The work one invocation may do, in the three units that run out. + * + * - **Bridge calls.** A sandboxed plugin gets `BRIDGE_BUDGET` per + * invocation: every `ctx.content`, `ctx.storage`, `ctx.kv`, `ctx.cron`, + * `ctx.log` and `ctx.http` call is one, and emdash aborts the invocation + * on the next. + * - **D1 queries.** In-process on Workers Free an invocation may make 50, + * emdash's own cron bookkeeping included. Most calls are one query, a + * content page is about three, and `putMany` is one per row. + * - **Outbound subrequests.** Also 50 in-process on Workers Free. emdash + * resolves every request's host over DNS-over-HTTPS first, A and AAAA, so + * a request is three. + * + * The plugin cannot tell whether it runs sandboxed, so every invocation + * stays inside all three. Phases draw from one `Budget` rather than counting + * for themselves, so a larger allowance only needs its number changed. + */ +export const BRIDGE_BUDGET = 10; +export const QUERY_BUDGET = 35; +export const OUTBOUND_BUDGET = 45; + +/** D1 queries of each kind of call, where it is more than one. */ +export const QUERIES = { + contentPage: 3, + cronSchedule: 2, + listCollections: 2, +} as const; + +export class Budget { + private calls = 0; + private queries = 0; + private outbound = 0; + + /** Bridge calls still available. */ + get left(): number { + return BRIDGE_BUDGET - this.calls; + } + + /** D1 queries still available. */ + get queriesLeft(): number { + return QUERY_BUDGET - this.queries; + } + + /** Outbound subrequests still available. */ + get outboundLeft(): number { + return OUTBOUND_BUDGET - this.outbound; + } + + /** + * Record bridge calls and the D1 queries behind them, made or set aside + * for calls the invocation will make last. + */ + spend(calls = 1, queries = calls): void { + this.calls += calls; + this.queries += queries; + } + + spendOutbound(subrequests: number): void { + this.outbound += subrequests; + } +} diff --git a/packages/content-link-check/src/check.ts b/packages/content-link-check/src/check.ts new file mode 100644 index 0000000..57d05d5 --- /dev/null +++ b/packages/content-link-check/src/check.ts @@ -0,0 +1,339 @@ +/** + * The HTTP check for a single URL, and the rule that turns a run of + * checks into a finding. + * + * Kept separate from the sweep so it can be tested with a fake fetch and + * so the retry policy lives in one place. + */ + +import type { PluginContext } from "emdash/plugin"; + +export type CheckState = + /** 2xx. */ + | "ok" + /** 3xx with a Location on an internal link — often fixable with a redirect rule. */ + | "redirect" + /** 404, 410, or a domain that no longer resolves — the link is wrong. */ + | "broken" + /** Anything that does not show whether the link works. See `CheckReason`. */ + | "unknown" + /** The URL does not parse. */ + | "malformed"; + +export type CheckReason = + /** 404 or 410. */ + | "not-found" + /** The hostname has no A or AAAA record. */ + | "dead-domain" + /** 5xx. Counts as a failure, but has to last before it is reported. */ + | "server-error" + /** 401 or 407: the target wants credentials. */ + | "auth" + /** 403 or 451: the target refuses this client, often a datacentre IP. */ + | "blocked" + /** 429. */ + | "rate-limited" + /** Any other status that is neither success nor a clear failure. */ + | "unexpected-status" + /** An outbound link redirected more often than the host follows. */ + | "too-many-redirects" + /** The request never produced a response. */ + | "network-error"; + +export interface CheckResult { + state: CheckState; + reason: CheckReason | null; + status: number | null; + /** Redirect target, when `state === "redirect"`. */ + location: string | null; + /** Where an outbound link ended up, when it redirected on the way. */ + finalUrl: string | null; + /** When a rate-limited target may be asked again, as an ISO timestamp. */ + retryAfter: string | null; + /** Error message, when the request never produced a response. */ + error: string | null; + /** Fetches the check made through `ctx.http`: none, HEAD, or HEAD and GET. */ + requests: number; + /** + * Outbound subrequests those fetches may have cost in-process: three per + * request, since emdash first resolves the host over DNS-over-HTTPS (A + * and AAAA), and up to six requests for a fetch that followed redirects. + * The response does not say how many hops it took, so that is charged in + * full. + */ + outbound: number; +} + +export interface CheckOptions { + /** + * An outbound link follows its redirects and is judged by the final + * response, since the hop is not something the site can fix. An internal + * hop is the finding, because core's redirect engine can fix it. + */ + outbound: boolean; +} + +/** + * What emdash's SSRF guard throws when a hostname has no A or AAAA record. + * The guard resolves every target before the request is made, so a dead + * domain surfaces as this rejection, not as a response, and the error has + * no code to match on. Any other resolver failure (a DoH timeout, SERVFAIL) + * reads differently and stays `unknown`. + */ +const NO_ADDRESSES = "Hostname resolved to no addresses"; + +const SUBREQUESTS_PER_REQUEST = 3; +/** emdash follows at most five redirects, so a fetch is at most six requests. */ +const REQUESTS_PER_FOLLOWED_FETCH = 6; + +/** The most outbound subrequests a check can cost: HEAD and GET, each at its worst. */ +export function worstOutbound(options: CheckOptions): number { + return 2 * fetchCost(options, true); +} + +function fetchCost(options: CheckOptions, redirected: boolean): number { + return SUBREQUESTS_PER_REQUEST * (options.outbound && redirected ? REQUESTS_PER_FOLLOWED_FETCH : 1); +} + +/** + * What emdash throws after following its maximum number of redirects — + * lower-case in-process, capitalised by the sandbox bridge. + */ +const TOO_MANY_REDIRECTS = /too many redirects/i; + +export function malformedResult(error: string): CheckResult { + return { + state: "malformed", + reason: null, + status: null, + location: null, + finalUrl: null, + retryAfter: null, + error, + requests: 0, + outbound: 0, + }; +} + +export async function checkUrl(ctx: PluginContext, url: string, options: CheckOptions): Promise { + if (!ctx.http) { + return noResponse("unknown", "network-error", "network capability not granted", 0, 0); + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return malformedResult("unparseable URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return malformedResult(`unsupported scheme ${parsed.protocol}`); + } + + const redirect = options.outbound ? "follow" : "manual"; + let requests = 0; + let outbound = 0; + try { + requests++; + // Charged at its worst before the fetch, so one that throws is still paid for. + outbound += fetchCost(options, true); + const head = await ctx.http.fetch(url, { method: "HEAD", redirect }); + outbound += fetchCost(options, head.redirected) - fetchCost(options, true); + // Sent to the URL the HEAD ended on, which saves the redirect hops. + const retry = retryWithGet(head.status, options); + if (retry) { + requests++; + outbound += fetchCost(options, true); + } + const response = retry ? await ctx.http.fetch(head.url || url, { method: "GET", redirect }) : head; + if (retry) outbound += fetchCost(options, response.redirected) - fetchCost(options, true); + + const { state, reason } = classifyStatus(response.status, options); + const landedAt = response.redirected ? response.url : head.redirected ? head.url : ""; + const finalUrl = landedAt && landedAt !== url ? landedAt : null; + return { + state, + reason, + status: response.status, + location: state === "redirect" ? response.headers.get("location") : null, + finalUrl, + retryAfter: + reason === "rate-limited" ? parseRetryAfter(response.headers.get("retry-after"), Date.now()) : null, + error: null, + requests, + outbound, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(NO_ADDRESSES)) return noResponse("broken", "dead-domain", message, requests, outbound); + if (TOO_MANY_REDIRECTS.test(message)) { + return noResponse("unknown", "too-many-redirects", message, requests, outbound); + } + return noResponse("unknown", "network-error", message, requests, outbound); + } +} + +function noResponse( + state: CheckState, + reason: CheckReason, + error: string, + requests: number, + outbound: number, +): CheckResult { + return { + state, + reason, + status: null, + location: null, + finalUrl: null, + retryAfter: null, + error, + requests, + outbound, + }; +} + +/** + * Whether a failed HEAD is worth a GET. Plenty of outbound servers answer + * HEAD with an error that GET does not give. The site's own pages answer + * HEAD as they answer GET, so an internal link only retries when HEAD is + * refused outright. A 429 asked us to back off and never gets a retry. + */ +function retryWithGet(status: number, options: CheckOptions): boolean { + if (status < 400 || status === 429) return false; + return options.outbound || status === 405 || status === 501; +} + +function classifyStatus( + status: number, + options: CheckOptions, +): { state: CheckState; reason: CheckReason | null } { + if (status >= 200 && status < 300) return { state: "ok", reason: null }; + // A 3xx that reaches us while following has no usable Location. + if (status >= 300 && status < 400) { + return options.outbound + ? { state: "unknown", reason: "unexpected-status" } + : { state: "redirect", reason: null }; + } + if (status === 404 || status === 410) return { state: "broken", reason: "not-found" }; + if (status === 401 || status === 407) return { state: "unknown", reason: "auth" }; + if (status === 403 || status === 451) return { state: "unknown", reason: "blocked" }; + if (status === 429) return { state: "unknown", reason: "rate-limited" }; + if (status >= 500) return { state: "unknown", reason: "server-error" }; + return { state: "unknown", reason: "unexpected-status" }; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** + * A `Retry-After` header as an ISO timestamp: delay-seconds or an HTTP + * date, held between a minute and a week so a hostile or broken value can + * neither hammer the target nor park the link for good. + */ +export function parseRetryAfter(value: string | null, now: number): string | null { + if (!value) return null; + const trimmed = value.trim(); + const at = /^\d+$/.test(trimmed) ? now + Number(trimmed) * 1000 : Date.parse(trimmed); + if (!Number.isFinite(at)) return null; + return new Date(Math.min(Math.max(at, now + MINUTE), now + 7 * DAY)).toISOString(); +} + +/** + * How long a failure has to persist before it is reported. The count + * threshold alone would let two manual sweeps a minute apart report a + * link. Twelve hours still reports a 404 on the second night of a nightly + * schedule; sixty hours is a server that failed three nightly sweeps. + */ +const NOT_FOUND_SPAN_MS = 12 * HOUR; +const SERVER_ERROR_SPAN_MS = 60 * HOUR; + +export interface LinkHistory { + state: CheckState; + reported: boolean; + consecutiveFailures: number; + firstFailedAt: string | null; +} + +export interface Verdict extends LinkHistory { + /** + * False when the result showed nothing and an earlier verdict stands. + * The caller then keeps that verdict's evidence too. + */ + conclusive: boolean; +} + +/** + * Fold one check result into a link's history. + * + * Only a clear failure advances the count. A blocked, rate-limited or + * unreachable target says nothing about the link either way, so the last + * conclusive verdict stands. A link already reported stays reported while + * it keeps failing, whatever the failure. + */ +export function judge( + previous: LinkHistory | null, + result: CheckResult, + now: Date, + threshold: number, +): Verdict { + if (result.state === "ok" || result.state === "redirect") { + return { + state: result.state, + reported: result.state === "redirect", + consecutiveFailures: 0, + firstFailedAt: null, + conclusive: true, + }; + } + + const serverError = result.reason === "server-error"; + if (result.state === "unknown" && !serverError) { + if (previous && previous.state !== "unknown") return { ...history(previous), conclusive: false }; + return { + state: "unknown", + reported: false, + consecutiveFailures: previous?.consecutiveFailures ?? 0, + firstFailedAt: previous?.firstFailedAt ?? null, + conclusive: true, + }; + } + + const consecutiveFailures = (previous?.consecutiveFailures ?? 0) + 1; + const firstFailedAt = previous?.firstFailedAt ?? now.toISOString(); + const failingFor = now.getTime() - Date.parse(firstFailedAt); + const counted = consecutiveFailures >= threshold; + const stillFailing = previous?.reported === true && (previous.state === "broken" || previous.state === "malformed"); + + // Waiting cannot fix a URL that does not parse, so the count alone decides. + if (result.state === "malformed") { + return { + state: "malformed", + reported: stillFailing || counted, + consecutiveFailures, + firstFailedAt, + conclusive: true, + }; + } + if (serverError) { + const reported = stillFailing || (counted && failingFor >= SERVER_ERROR_SPAN_MS); + return { state: reported ? "broken" : "unknown", reported, consecutiveFailures, firstFailedAt, conclusive: true }; + } + return { + state: "broken", + reported: stillFailing || (counted && failingFor >= NOT_FOUND_SPAN_MS), + consecutiveFailures, + firstFailedAt, + conclusive: true, + }; +} + +function history(previous: LinkHistory): LinkHistory { + return { + state: previous.state, + reported: previous.reported, + consecutiveFailures: previous.consecutiveFailures, + firstFailedAt: previous.firstFailedAt ?? null, + }; +} diff --git a/packages/content-link-check/src/extract.ts b/packages/content-link-check/src/extract.ts new file mode 100644 index 0000000..8045ded --- /dev/null +++ b/packages/content-link-check/src/extract.ts @@ -0,0 +1,142 @@ +/** + * Link extraction — pure functions, no `ctx`, no network. + * + * Deliberately schema-agnostic. Field definitions rarely say which strings + * are links: sites store them in plain `string` fields and inside repeaters + * as often as in `url` fields. So instead of knowing where links live, this + * walks the entry's `data` and picks up anything that unambiguously is one: + * + * - every `http(s)` URL appearing in any string value, wherever it sits + * — a bare URL pasted into prose counts, and so does Portable Text's + * `markDefs[].href`, which is just an `href` key a few levels down; + * - site-relative paths (`/about`), but only under keys that mean + * "this is a link" (`href`, or a name ending in one, like `cta_url`) — + * otherwise ordinary prose containing a slash would be collected as a + * link. + * + * The trade is deliberate: this over-collects slightly (a URL written in + * a code sample is indistinguishable from a real link) and never + * under-collects. A false positive shows up in the report as a link that + * resolves fine; a false negative is a broken link nobody sees. + */ + +/** One link, with where it was found, for the report table. */ +export interface ExtractedLink { + /** The href exactly as written in the content. */ + url: string; + /** Dotted path into the entry data, e.g. `body[2].markDefs[0].href`. */ + path: string; +} + +/** Keys whose string values are treated as links even when relative. */ +const LINK_KEYS = new Set(["href", "url", "canonical", "link", "src", "image"]); + +/** Custom field names ending in a link word: `cta_url`, `link_href`, `ctaHref`. */ +const LINK_KEY_SUFFIX = /(?:[_-](?:url|href|link)|[a-z0-9](?:Url|Href|Link))$/; + +function isLinkKey(key: string): boolean { + return LINK_KEYS.has(key) || LINK_KEY_SUFFIX.test(key); +} + +/** Whole-string absolute URL. */ +const ABSOLUTE = /^https?:\/\//i; + +/** + * URL-shaped substrings, for a bare URL sitting inside prose. Stops at + * whitespace and at the characters that normally *enclose* a URL rather + * than belong to it. + */ +const URL_IN_TEXT = /https?:\/\/[^\s<>"')\]]+/gi; + +/** Punctuation that ends the sentence, not the URL. */ +const TRAILING_PUNCTUATION = /[.,;:!?]+$/; + +/** + * Depth cap. Content is JSON, so there are no cycles to guard against, + * but a sandboxed invocation has a 50 ms CPU budget and a pathological + * nesting depth is not worth spending it on. + */ +const MAX_DEPTH = 12; + +/** + * Collect every link in an entry's `data` (and its `seo` block, if the + * caller merges it in). Deduplicated by URL, first path wins, order + * stable so the report does not reshuffle between sweeps. + */ +export function extractLinks(data: unknown, maxDepth: number = MAX_DEPTH): ExtractedLink[] { + const found = new Map(); + + const visit = (value: unknown, path: string, key: string | null, depth: number): void => { + if (depth > maxDepth) return; + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return; + + // A field holding exactly one URL is the common case; scanning + // covers the rest, including a URL mid-sentence. + for (const match of trimmed.match(URL_IN_TEXT) ?? []) { + const url = ABSOLUTE.test(trimmed) && match === trimmed ? trimmed : match.replace(TRAILING_PUNCTUATION, ""); + if (url && !found.has(url)) found.set(url, path); + } + + if (key !== null && isLinkKey(key) && isRelativePath(trimmed) && !found.has(trimmed)) { + found.set(trimmed, path); + } + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => visit(item, `${path}[${index}]`, null, depth + 1)); + return; + } + + if (value !== null && typeof value === "object") { + for (const [childKey, childValue] of Object.entries(value)) { + const childPath = path ? `${path}.${childKey}` : childKey; + visit(childValue, childPath, childKey, depth + 1); + } + } + }; + + visit(data, "", null, 0); + return [...found].map(([url, path]) => ({ url, path })); +} + +/** + * A site-relative path: starts with a single slash, and is not a + * protocol-relative URL (`//host`) or a bare slash. + */ +export function isRelativePath(value: string): boolean { + return value.startsWith("/") && !value.startsWith("//") && value.length > 1; +} + +/** Where a link points, relative to this site. */ +export type LinkScope = "internal" | "external"; + +/** + * Classify a link against the site's own origin. A same-origin absolute + * URL counts as internal — editors paste those constantly, and treating + * them as external would check the site from the outside for no reason. + */ +export function classify(url: string, siteUrl: string): LinkScope { + if (isRelativePath(url)) return "internal"; + try { + return new URL(url).origin === new URL(siteUrl).origin ? "internal" : "external"; + } catch { + return "external"; + } +} + +/** + * Absolute URL to actually request. Relative paths are resolved against + * the site origin. Returns `null` for anything that will not parse, so + * the caller can record it as malformed rather than fetch garbage. + */ +export function toRequestUrl(url: string, siteUrl: string): string | null { + try { + return new URL(url, siteUrl).toString(); + } catch { + return null; + } +} diff --git a/packages/content-link-check/src/plugin.ts b/packages/content-link-check/src/plugin.ts new file mode 100644 index 0000000..8e0f201 --- /dev/null +++ b/packages/content-link-check/src/plugin.ts @@ -0,0 +1,207 @@ +/** + * content-link-check — scheduled link audit for EmDash content. + * + * Standard plugin format: this one file is the entry point in both + * trusted (in-process, `plugins: []`) and sandboxed (`sandboxed: []`) + * mode. The logic lives in the sibling modules; this is wiring. + */ + +import { + followUpPending, + hasRole, + isFollowUp, + ROLE, + scheduleFollowUp, + type SweepChain, +} from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; + +import { Budget } from "./budget.js"; +import { + buildReportPage, + buildSettingsPage, + buildSweepRequested, + buildWidget, + countLinks, + REPORT_PAGE_ACTION, +} from "./report.js"; +import { mergeSettings, readSettings, SETTINGS_KEY, type Settings } from "./settings.js"; +import { loadAll } from "./state.js"; +import { requestSweep, sweepTick } from "./sweep.js"; + +interface AdminInteraction { + type: string; + page?: string; + action_id?: string; + values?: Record; +} + +/** + * The recurring task, and the one-shot the "Sweep 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 nightly sweep with a task that runs once and never again. The + * button schedules its own name instead, and the cron handler answers to + * both. + */ +const SWEEP_TASK = "sweep"; +const SWEEP_NOW_TASK = "sweep-now"; + +/** + * A sweep carries on in follow-up runs until it is done. Each run is one + * small step, so the cap is about a day of runs on a per-minute cron + * trigger; a sweep that hits it carries on from its next scheduled start. + */ +const CHAIN: SweepChain = { next: ["sweep-next-a", "sweep-next-b"], maxSteps: 1400 }; + +const plugin = { + hooks: { + /** + * Register the sweep when the plugin is switched on at runtime. A plugin + * listed in the Astro config is never switched on this way; see + * `ensureScheduled` for how that one gets its sweep. + */ + "plugin:activate": { + handler: async (_event, ctx) => scheduleSweep(ctx, (await readSettings(ctx)).schedule), + }, + + cron: { + handler: async (event, ctx) => { + const start = event.name === SWEEP_TASK || event.name === SWEEP_NOW_TASK; + if (!start && !isFollowUp(CHAIN, event.name)) return; + + const budget = new Budget(); + if (start) { + budget.spend(); + // A follow-up already waiting means a sweep is under way. + if (await followUpPending(ctx, CHAIN)) return; + } + if (!(await sweepTick(ctx, budget, { start }))) return; + // The follow-up call the run set aside pays for the warning when there is no follow-up. + if (!(await scheduleFollowUp(ctx, CHAIN, event))) { + ctx.log.warn(`Sweep paused after ${CHAIN.maxSteps} runs; it carries on from its next scheduled start`); + } + }, + }, + + /** A site whose admin nobody opens still gets its sweep; see `ensureScheduled`. */ + "content:afterSave": { + errorPolicy: "continue", + handler: async (_event, ctx) => ensureScheduled(ctx), + }, + "content:afterPublish": { + errorPolicy: "continue", + handler: async (_event, ctx) => ensureScheduled(ctx), + }, + }, + + routes: { + /** Every Block Kit surface arrives here. */ + admin: { + // Without this the route resolves with no permission and dispatch + // falls back to `plugins:manage`, which is administrators only, so + // every editor sees the widget card and a permission error inside + // it. `plugins:read` is editor and above. Settings submissions are + // held to administrators inside the handler, because a route carries + // one permission and this one serves both a report and a form. + permission: "plugins:read", + handler: async (routeCtx, ctx) => { + const interaction = routeCtx.input as AdminInteraction; + + if (interaction.type === "page_load") { + const { settings, state, requestedAt } = await loadAll(ctx); + await ensureScheduled(ctx, settings); + if (interaction.page === "widget:summary") return buildWidget(ctx, state, requestedAt); + if (interaction.page === "/settings") return buildSettingsPage(settings); + return buildReportPage(ctx); + } + + if (interaction.type === "form_submit" && interaction.action_id === "save_settings") { + const current = await readSettings(ctx); + if (!hasRole(routeCtx.user, ROLE.ADMIN)) { + return { + ...buildSettingsPage(current), + toast: { message: "Only an administrator can change these settings.", type: "error" }, + }; + } + const next = mergeSettings(current, interaction.values ?? {}); + // Scheduled before anything is stored: emdash rejects a cron + // expression it cannot parse, and a saved value the sweep does not + // run on would be worse than a refused save. + try { + await scheduleSweep(ctx, next.schedule); + } catch { + return { + ...buildSettingsPage(current), + toast: { message: "That schedule is not a cron expression emdash accepts. Nothing was saved.", type: "error" }, + }; + } + await ctx.kv.set(SETTINGS_KEY, next); + return buildSettingsPage(next); + } + + if (interaction.type === "block_action") { + if (interaction.action_id === "sweep_now") { + await requestSweep(ctx); + await ctx.cron?.schedule(SWEEP_NOW_TASK, { schedule: new Date(Date.now() + 1000).toISOString() }); + return buildSweepRequested(); + } + if (interaction.action_id === REPORT_PAGE_ACTION) return buildReportPage(ctx); + } + + return { blocks: [] }; + }, + }, + + /** JSON counts, for a status check or an external dashboard. */ + status: { + handler: async (_routeCtx, ctx) => { + const [{ state }, counts] = await Promise.all([loadAll(ctx), countLinks(ctx)]); + return { + phase: state.phase, + startedAt: state.startedAt, + finishedAt: state.finishedAt, + collections: state.collections, + counts, + }; + }, + }, + }, +} satisfies SandboxedPlugin; + +/** + * `satisfies` above for the per-hook inference (`event` typed by hook + * name), and an explicit type on the export because the inferred shape + * cannot be named from a pnpm nested path — `tsc` rejects the generated + * declaration with TS2742 otherwise. + */ +export default plugin as SandboxedPlugin; + +async function scheduleSweep(ctx: PluginContext, schedule: string): Promise { + await ctx.cron?.schedule(SWEEP_TASK, { schedule }); +} + +/** Set once this isolate has seen the sweep registered on the saved schedule. */ +let sweepConfirmed = false; + +/** + * Register the nightly sweep if it is missing or runs on another schedule + * than the saved one. emdash only fires `plugin:activate` when a plugin is + * switched on at runtime, never for one listed in the Astro config, and the + * manifest cannot declare a schedule. So admin page loads and saves check, + * once per isolate. A failure here never costs the caller the work it came + * for. + */ +async function ensureScheduled(ctx: PluginContext, known?: Settings): Promise { + if (sweepConfirmed || !ctx.cron) return; + try { + const [tasks, settings] = await Promise.all([ctx.cron.list(), known ?? readSettings(ctx)]); + const sweep = tasks.find((task) => task.name === SWEEP_TASK); + if (sweep?.schedule !== settings.schedule) await scheduleSweep(ctx, settings.schedule); + sweepConfirmed = true; + } catch { + // Logging would spend a bridge call the caller may not have; the next + // load or save tries again. + } +} diff --git a/packages/content-link-check/src/report.ts b/packages/content-link-check/src/report.ts new file mode 100644 index 0000000..9c55080 --- /dev/null +++ b/packages/content-link-check/src/report.ts @@ -0,0 +1,231 @@ +/** + * Block Kit surfaces: the report page, the settings form, and the + * dashboard widget. + * + * Block Kit is the whole admin UI for a sandboxed plugin — `table` + * carries sorting and pagination, `stats` gives the metric cards, and + * `empty` covers the clean state. No React, and no editor slot needed. + */ + +import type { PluginContext } from "emdash/plugin"; + +import type { Settings } from "./settings.js"; +import type { SweepState } from "./state.js"; +import { FINDINGS, type CheckRow } from "./store.js"; + +type Block = Record; + +/** Block Kit requires a table to name the action its paging and sorting send. */ +export const REPORT_PAGE_ACTION = "report_page"; + +const REPORT_LIMIT = 100; + +export interface Counts { + broken: number; + redirect: number; + unknown: number; + /** Targets checked at least once. */ + total: number; +} + +/** Counts of distinct targets, so the widget and the report agree. */ +export async function countLinks(ctx: PluginContext): Promise { + const [broken, redirect, unknown, total] = await Promise.all([ + ctx.storage.checks.count({ finding: { in: ["broken", "malformed"] } }), + ctx.storage.checks.count({ finding: "redirect" }), + ctx.storage.checks.count({ state: "unknown" }), + ctx.storage.checks.count({ state: { in: ["ok", "redirect", "broken", "unknown", "malformed"] } }), + ]); + return { broken, redirect, unknown, total }; +} + +function progress(state: SweepState, requestedAt: string | null): string { + if (requestedAt && requestedAt > (state.startedAt ?? "")) { + return "A full sweep has been requested; it starts with the next run of the site's cron trigger."; + } + if (state.phase === "done") { + return state.finishedAt ? `Last sweep finished ${state.finishedAt.slice(0, 10)}.` : "No sweep has run yet."; + } + return `A sweep is under way (${state.phase === "check" ? "checking links" : "reading content"}).`; +} + +export async function buildWidget( + ctx: PluginContext, + state: SweepState, + requestedAt: string | null, +): Promise<{ blocks: Block[] }> { + const counts = await countLinks(ctx); + + if (counts.total === 0) { + return { + blocks: [ + { + type: "empty", + icon: "link", + title: "Nothing checked yet", + description: + state.phase === "done" && !state.finishedAt && !requestedAt + ? "The first sweep runs on the schedule in Link check settings." + : progress(state, requestedAt), + }, + ], + }; + } + + return { + blocks: [ + { + type: "stats", + items: [ + { label: "Broken", value: String(counts.broken) }, + { label: "Redirects", value: String(counts.redirect) }, + { label: "Checked", value: String(counts.total) }, + ], + }, + { type: "context", text: progress(state, requestedAt) }, + ], + }; +} + +/** What the report shows right after "Run a full sweep now". */ +export function buildSweepRequested(): { blocks: Block[] } { + return { + blocks: [ + { type: "header", text: "Link report" }, + { + type: "context", + text: "A full sweep has been requested. It starts with the next run of the site's cron trigger; reload this page to follow it.", + }, + ], + }; +} + +/** + * Reported targets, one row each, with the first place it is linked from + * and how many more there are. A table row per place would let one link + * repeated across a few hundred entries push the page past emdash's Block + * Kit limits, and emdash then rejects the whole report. + */ +export async function buildReportPage(ctx: PluginContext): Promise<{ blocks: Block[] }> { + const counts = await countLinks(ctx); + const findings = await ctx.storage.checks.query({ where: { finding: { in: FINDINGS } }, limit: REPORT_LIMIT }); + const checks = findings.items + .map(({ data }) => data as CheckRow) + .sort((a, b) => a.state.localeCompare(b.state) || a.target.localeCompare(b.target)); + + if (checks.length === 0) { + return { + blocks: [ + { type: "header", text: "Link report" }, + { + type: "empty", + icon: "check", + title: counts.total === 0 ? "Nothing checked yet" : "No problems found", + description: + counts.total === 0 + ? "The first sweep runs on the schedule in Link check settings." + : `${counts.total} links checked, none broken.`, + }, + ], + }; + } + + const multilingual = new Set(checks.map((check) => check.places?.[0]?.locale ?? null)).size > 1; + const reported = counts.broken + counts.redirect; + + return { + blocks: [ + { type: "header", text: "Link report" }, + { + type: "stats", + items: [ + { label: "Broken", value: String(counts.broken) }, + { label: "Redirects", value: String(counts.redirect) }, + { label: "Unresolved", value: String(counts.unknown) }, + { label: "Checked", value: String(counts.total) }, + ], + }, + ...(reported > checks.length + ? [{ type: "context", text: `Showing ${checks.length} of ${reported} findings.` }] + : []), + { + type: "table", + columns: [ + { key: "state", label: "State" }, + { key: "status", label: "Status" }, + { key: "url", label: "Link" }, + { key: "entry", label: "Found in" }, + ...(multilingual ? [{ key: "locale", label: "Language" }] : []), + { key: "where", label: "Field" }, + { key: "since", label: "First seen" }, + ], + rows: checks.map((check) => { + const first = check.places?.[0]; + const more = (check.placeCount ?? 0) - 1; + return { + state: check.state, + status: check.status === null ? "—" : String(check.status), + url: first?.url ?? check.target, + entry: first + ? `${first.collection}/${first.entrySlug ?? first.entryId}${more > 0 ? ` +${more} more` : ""}` + : "—", + locale: first?.locale ?? "", + where: first?.path ?? "", + since: (check.firstSeenAt ?? check.checkedAt).slice(0, 10), + }; + }), + page_action_id: REPORT_PAGE_ACTION, + }, + { + type: "actions", + elements: [{ type: "button", action_id: "sweep_now", label: "Run a full sweep now" }], + }, + ], + }; +} + +export function buildSettingsPage(settings: Settings): { blocks: Block[] } { + return { + blocks: [ + { type: "header", text: "Link check settings" }, + { + type: "context", + text: "Every collection on the site is checked.", + }, + { + type: "form", + submit: { label: "Save", action_id: "save_settings" }, + fields: [ + { + type: "number_input", + action_id: "batchSize", + label: "Entries per step", + min: 1, + max: 100, + initial_value: settings.batchSize, + }, + { + type: "text_input", + action_id: "schedule", + label: "Schedule (cron)", + initial_value: settings.schedule, + }, + { + type: "toggle", + action_id: "checkExternal", + label: "Check outbound links", + initial_value: settings.checkExternal, + }, + { + type: "number_input", + action_id: "failureThreshold", + label: "Failures before reporting", + min: 1, + max: 10, + initial_value: settings.failureThreshold, + }, + ], + }, + ], + }; +} diff --git a/packages/content-link-check/src/settings.ts b/packages/content-link-check/src/settings.ts new file mode 100644 index 0000000..43b28f7 --- /dev/null +++ b/packages/content-link-check/src/settings.ts @@ -0,0 +1,60 @@ +/** + * Settings, stored in plugin KV as one object under `settings`, so any + * invocation reads them in one bridge call. + * + * A standard-format (sandboxed) plugin has no declarative + * `settingsSchema` — that is a native-plugin feature. So the form is + * built by hand in `report.ts` and written through here, which is also + * what upstream's own `webhook-notifier` does. + */ + +import { clampNumber } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +export { clampNumber }; + +export const SETTINGS_KEY = "settings"; + +export interface Settings { + /** Entries read per step of the walk over the site's content. */ + batchSize: number; + /** Cron expression for the sweep. */ + schedule: string; + /** Check outbound links, not just internal ones. */ + checkExternal: boolean; + /** Consecutive failures before a link is reported as broken. */ + failureThreshold: number; +} + +export const DEFAULT_SETTINGS: Settings = { + batchSize: 25, + schedule: "0 3 * * *", + checkExternal: true, + failureThreshold: 2, +}; + +/** Settings from whatever is stored, each value falling back to its default. */ +export function settingsFrom(stored: unknown): Settings { + const raw = (stored ?? {}) as Partial>; + return { + batchSize: clampNumber(raw.batchSize, 1, 100, DEFAULT_SETTINGS.batchSize), + schedule: + typeof raw.schedule === "string" && raw.schedule.trim() ? raw.schedule.trim() : DEFAULT_SETTINGS.schedule, + checkExternal: typeof raw.checkExternal === "boolean" ? raw.checkExternal : DEFAULT_SETTINGS.checkExternal, + failureThreshold: clampNumber(raw.failureThreshold, 1, 10, DEFAULT_SETTINGS.failureThreshold), + }; +} + +export async function readSettings(ctx: PluginContext): Promise { + return settingsFrom(await ctx.kv.get(SETTINGS_KEY)); +} + +/** Submitted form values over the current settings; unknown keys are ignored. */ +export function mergeSettings(current: Settings, values: Record): Settings { + return settingsFrom({ + batchSize: values.batchSize ?? current.batchSize, + schedule: typeof values.schedule === "string" && values.schedule.trim() ? values.schedule : current.schedule, + checkExternal: typeof values.checkExternal === "boolean" ? values.checkExternal : current.checkExternal, + failureThreshold: values.failureThreshold ?? current.failureThreshold, + }); +} diff --git a/packages/content-link-check/src/state.ts b/packages/content-link-check/src/state.ts new file mode 100644 index 0000000..a9db2f9 --- /dev/null +++ b/packages/content-link-check/src/state.ts @@ -0,0 +1,82 @@ +/** + * Where the sweep stands, stored in plugin KV as one object under `state` + * so a tick reads it, together with the settings, in one bridge call. + */ + +import type { PluginContext } from "emdash/plugin"; + +import { SETTINGS_KEY, settingsFrom, type Settings } from "./settings.js"; + +export const STATE_KEY = "state"; + +/** + * When "Run a full sweep now" was last pressed. Its own key, because a + * running tick rewrites the whole state object and would drop a request + * written into it. + */ +export const REQUEST_KEY = "sweep-requested"; + +export interface SweepState { + /** + * `begin` asks the next run to start a sweep, `walk` reads the content + * and records every link, `prune` deletes what the walk no longer found, + * `check` requests the targets not yet checked this sweep. + */ + phase: "begin" | "walk" | "prune" | "check" | "done"; + /** When the current or last sweep began. Null before the first one. */ + startedAt: string | null; + finishedAt: string | null; + /** Collection slugs, fixed when the sweep begins. */ + collections: string[]; + collectionIndex: number; + cursor: string | null; + /** + * Entries per walk step: the setting, shrunk when a page has more targets + * than a step may write, and grown back afterwards. + */ + pageSize: number | null; + /** Hosts that answered 429, each with the time it may be asked again. */ + backoff: Record; +} + +export const IDLE_STATE: SweepState = { + phase: "done", + startedAt: null, + finishedAt: null, + collections: [], + collectionIndex: 0, + cursor: null, + pageSize: null, + backoff: {}, +}; + +export function stateFrom(stored: unknown): SweepState { + return { ...IDLE_STATE, ...((stored ?? {}) as Partial) }; +} + +/** Settings, sweep state and any pending sweep request, in one bridge call. */ +export async function loadAll( + ctx: PluginContext, +): Promise<{ settings: Settings; state: SweepState; requestedAt: string | null }> { + const entries = new Map((await ctx.kv.list()).map(({ key, value }) => [key, value])); + const requested = entries.get(REQUEST_KEY); + return { + settings: settingsFrom(entries.get(SETTINGS_KEY)), + state: stateFrom(entries.get(STATE_KEY)), + requestedAt: typeof requested === "string" ? requested : null, + }; +} + +/** A fresh sweep over `collections`, starting now. */ +export function beginSweep(state: SweepState, collections: string[], now: Date): SweepState { + return { + ...state, + phase: "walk", + startedAt: now.toISOString(), + finishedAt: null, + collections, + collectionIndex: 0, + cursor: null, + pageSize: null, + }; +} diff --git a/packages/content-link-check/src/store.ts b/packages/content-link-check/src/store.ts new file mode 100644 index 0000000..03a54a9 --- /dev/null +++ b/packages/content-link-check/src/store.ts @@ -0,0 +1,95 @@ +/** + * The stored record: one row per link target — the URL actually requested + * — checked once and shared by every entry that links to it. + * + * A row also carries the first few places that link to it and how many + * there are, rebuilt by every sweep. Keeping them on the row rather than one + * row per link keeps a sweep's writes to one per target, which matters on + * Workers Free: `putMany` issues one query per row and an invocation may + * make 50. `seenAt` is the start of the last sweep that found the target + * linked; a sweep deletes what it did not stamp, which is how a removed + * link, an unpublished entry or a deleted one leaves the report. + */ + +import type { CheckReason, LinkHistory } from "./check.js"; + +export type Finding = "broken" | "malformed" | "redirect"; + +export const FINDINGS: Finding[] = ["broken", "malformed", "redirect"]; + +/** Places kept on a row; the rest are only counted. */ +export const PLACES_KEPT = 3; + +/** + * Ids or values one `IN` list may carry. D1 binds at most 100 parameters + * per query and emdash's plugin storage adds its own two, without chunking. + */ +export const MAX_IDS = 48; + +export interface Place { + /** The link exactly as written in the entry. */ + url: string; + /** Dotted path into the entry data, e.g. `body[2].markDefs[0].href`. */ + path: string; + collection: string; + entryId: string; + entrySlug: string | null; + locale: string | null; +} + +export interface CheckRow extends Omit { + /** `pending` until the target's first check. */ + state: LinkHistory["state"] | "pending"; + /** The URL requested, without its fragment. Also the row id. */ + target: string; + scope: "internal" | "external"; + reason: CheckReason | null; + status: number | null; + location: string | null; + finalUrl: string | null; + error: string | null; + /** `state` once the target is reported, null until then. */ + finding: Finding | null; + /** Empty until the first check, so a range query finds it as due. */ + checkedAt: string; + firstSeenAt: string; + seenAt: string; + /** The walk page that last stamped the row, so a page read again does not count its places twice. */ + lastPage: string; + places: Place[]; + placeCount: number; +} + +export interface Stored { + id: string; + data: T; +} + +export function findingFor(verdict: LinkHistory): Finding | null { + if (!verdict.reported) return null; + return FINDINGS.find((finding) => finding === verdict.state) ?? null; +} + +/** A target nothing has checked yet. */ +export function pendingCheck(target: string, scope: CheckRow["scope"], seenAt: string, places: Place[], placeCount: number): CheckRow { + return { + target, + scope, + state: "pending", + reason: null, + status: null, + location: null, + finalUrl: null, + error: null, + consecutiveFailures: 0, + firstFailedAt: null, + reported: false, + finding: null, + checkedAt: "", + firstSeenAt: seenAt, + seenAt, + lastPage: "", + places, + placeCount, + }; +} diff --git a/packages/content-link-check/src/sweep.ts b/packages/content-link-check/src/sweep.ts new file mode 100644 index 0000000..a62ba0d --- /dev/null +++ b/packages/content-link-check/src/sweep.ts @@ -0,0 +1,302 @@ +/** + * The sweep, one bounded step at a time. + * + * Every invocation draws from one `Budget` of bridge calls, D1 queries and + * outbound subrequests (see `budget.ts`), so each phase batches its storage + * work and stops when the budget would not cover its next step: + * + * 1. **walk** — read a page of published entries and write one row per + * target they link to, stamped with the sweep's start and carrying the + * places it is linked from. A target not seen before is stored as + * pending. + * 2. **prune** — delete the targets this sweep did not stamp: links taken + * out, entries unpublished or deleted. + * 3. **check** — request the targets not checked since the sweep began, + * as many as the budget allows, and store the verdicts. + * + * The phase, collection, cursor and host backoff live in one KV object. + * Every step can be repeated: a run that dies before saving the state is + * followed by one that redoes its step without counting anything twice. + */ + +import { listCollections } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; + +import { QUERIES, type Budget } from "./budget.js"; +import { checkUrl, judge, worstOutbound, type LinkHistory } from "./check.js"; +import { classify, extractLinks, toRequestUrl, type LinkScope } from "./extract.js"; +import type { Settings } from "./settings.js"; +import { beginSweep, loadAll, REQUEST_KEY, STATE_KEY, type SweepState } from "./state.js"; +import { findingFor, MAX_IDS, PLACES_KEPT, pendingCheck, type CheckRow, type Place } from "./store.js"; + +/** HEAD, and the GET a failed HEAD may earn. */ +const CALLS_PER_LINK = 2; +/** A page linking to more targets than this is read in fewer entries. */ +const PAGE_TARGETS = 20; +/** Targets of one entry that are recorded; an entry linking to more is cut short. */ +const ENTRY_TARGETS = 2 * MAX_IDS; + +/** + * One run of the sweep. `start` is a scheduled start: it begins a new sweep + * if none is under way, and otherwise lets the current one carry on. A + * requested sweep begins at whichever run comes next. Returns whether there + * is more to do. + */ +export async function sweepTick(ctx: PluginContext, budget: Budget, options: { start: boolean }): Promise { + const { settings, state: loaded, requestedAt } = await loadAll(ctx); + budget.spend(); + + const requested = requestedAt !== null && requestedAt > (loaded.startedAt ?? ""); + let state = (options.start && loaded.phase === "done") || requested ? { ...loaded, phase: "begin" as const } : loaded; + if (state.phase === "done") return false; + if (state.phase === "begin") { + const collections = (await listCollections(ctx)).map((collection) => collection.slug); + budget.spend(1, QUERIES.listCollections); + state = beginSweep(state, collections, new Date()); + } + + // The state write and the follow-up that end every run that did work. + budget.spend(2, 1 + QUERIES.cronSchedule); + + while (state.phase === "walk") { + const step = await walk(ctx, budget, settings, state); + state = step.state; + if (!step.progressed) break; + } + while (state.phase === "prune" && budget.left >= 2 && budget.queriesLeft >= 2) state = await prune(ctx, budget, state); + if (state.phase === "check" && budget.left >= 2 + CALLS_PER_LINK && budget.queriesLeft >= 3) { + state = await check(ctx, budget, settings, state); + } + + await ctx.kv.set(STATE_KEY, state); + return state.phase !== "done"; +} + +/** Asks the next run to begin a fresh sweep. */ +export async function requestSweep(ctx: PluginContext): Promise { + await ctx.kv.set(REQUEST_KEY, new Date().toISOString()); +} + +interface Linked { + scope: LinkScope; + places: Place[]; + count: number; +} + +async function walk( + ctx: PluginContext, + budget: Budget, + settings: Settings, + state: SweepState, +): Promise<{ state: SweepState; progressed: boolean }> { + const collection = state.collections[state.collectionIndex]; + if (collection === undefined || !ctx.content || !state.startedAt) { + return { state: { ...state, phase: "prune", cursor: null }, progressed: true }; + } + // A page, one lookup and one write, at the least. + if (budget.left < 3 || budget.queriesLeft < QUERIES.contentPage + 2) return { state, progressed: false }; + const seenAt = state.startedAt; + const pageSize = state.pageSize ?? settings.batchSize; + const pageKey = `${collection}:${state.cursor ?? ""}:${pageSize}`; + + const page = await ctx.content.list(collection, { + limit: pageSize, + where: { status: "published" }, + ...(state.cursor ? { cursor: state.cursor } : {}), + }); + budget.spend(1, QUERIES.contentPage); + + const linked = linksIn(page.items, collection, ctx.site.url, settings); + const targets = [...linked.keys()]; + if (targets.length > PAGE_TARGETS && page.items.length > 1) { + const smaller = Math.max(1, Math.floor((page.items.length * PAGE_TARGETS) / targets.length)); + return { state: { ...state, pageSize: smaller }, progressed: true }; + } + + const lookups = chunk(targets.slice(0, ENTRY_TARGETS), MAX_IDS); + if (budget.left < lookups.length + 1 || budget.queriesLeft < lookups.length + 1) return { state, progressed: false }; + const known = new Map(); + for (const ids of lookups) { + for (const [id, row] of await ctx.storage.checks.getMany(ids)) known.set(id, row as CheckRow); + budget.spend(); + } + + const writes = lookups.flat().flatMap((target) => { + const row = stamped(known.get(target), target, linked.get(target)!, seenAt, pageKey); + return row ? [{ id: target, data: row }] : []; + }); + const now = writes.slice(0, budget.queriesLeft); + if (now.length > 0) { + await ctx.storage.checks.putMany(now); + budget.spend(1, now.length); + } + // What did not fit is written when the page is read again; what was + // written is then recognised by its page key and skipped. + if (now.length < writes.length) return { state, progressed: false }; + + const grown = Math.min(settings.batchSize, pageSize * 2); + const next = { ...state, pageSize: grown >= settings.batchSize ? null : grown }; + return { + state: page.cursor + ? { ...next, cursor: page.cursor } + : { ...next, collectionIndex: state.collectionIndex + 1, cursor: null }, + progressed: true, + }; +} + +/** Every target the entries link to, with where, in the order found. */ +function linksIn( + entries: Array<{ id: string; slug?: string | null; locale?: string | null; data: unknown; seo?: unknown }>, + collection: string, + siteUrl: string, + settings: Settings, +): Map { + const linked = new Map(); + for (const entry of entries) { + for (const link of extractLinks({ ...(entry.data as object), seo: entry.seo })) { + const scope = classify(link.url, siteUrl); + if (scope === "external" && !settings.checkExternal) continue; + const requestUrl = toRequestUrl(link.url, siteUrl); + const target = requestUrl ? withoutFragment(requestUrl) : link.url; + const seen = linked.get(target) ?? { scope, places: [], count: 0 }; + seen.count++; + if (seen.places.length < PLACES_KEPT) { + seen.places.push({ + url: link.url, + path: link.path, + collection, + entryId: entry.id, + entrySlug: entry.slug ?? null, + locale: entry.locale ?? null, + }); + } + linked.set(target, seen); + } + } + return linked; +} + +/** + * A target's row with this sweep's stamp, or null when this page already + * stamped it. The places start over with each sweep, so an entry that + * stopped linking drops out of them. + */ +function stamped( + known: CheckRow | undefined, + target: string, + found: Linked, + seenAt: string, + pageKey: string, +): CheckRow | null { + if (!known) return { ...pendingCheck(target, found.scope, seenAt, found.places, found.count), lastPage: pageKey }; + const sameSweep = known.seenAt === seenAt; + if (sameSweep && known.lastPage === pageKey) return null; + return { + ...known, + seenAt, + lastPage: pageKey, + places: [...(sameSweep ? (known.places ?? []) : []), ...found.places].slice(0, PLACES_KEPT), + placeCount: (sameSweep ? (known.placeCount ?? 0) : 0) + found.count, + }; +} + +async function prune(ctx: PluginContext, budget: Budget, state: SweepState): Promise { + const stale = await ctx.storage.checks.query({ + where: { seenAt: { lt: state.startedAt ?? "" } }, + limit: MAX_IDS, + }); + budget.spend(); + if (stale.items.length === 0) return { ...state, phase: "check" }; + + await ctx.storage.checks.deleteMany(stale.items.map((item) => item.id)); + budget.spend(); + return state; +} + +async function check(ctx: PluginContext, budget: Budget, settings: Settings, state: SweepState): Promise { + const due = await ctx.storage.checks.query({ + where: { checkedAt: { lt: state.startedAt ?? "" } }, + limit: Math.max(1, Math.min(MAX_IDS, budget.queriesLeft - 2, budget.left)), + }); + budget.spend(); + if (due.items.length === 0) return { ...state, phase: "done", finishedAt: new Date().toISOString() }; + + // The `putMany` below; its rows are charged once they are known. + budget.spend(1, 0); + const backoff = { ...state.backoff }; + const updates: Array<{ id: string; data: CheckRow }> = []; + for (const { id, data } of due.items) { + if (budget.queriesLeft - updates.length < 1) break; + const row = data as CheckRow; + const now = new Date(); + const host = hostOf(row.target); + const backoffUntil = host ? backoff[host] : undefined; + if (backoffUntil && Date.parse(backoffUntil) > now.getTime()) { + // Counted as checked for this sweep, so a host that keeps a whole + // batch waiting cannot stall the targets behind it. + updates.push({ id, data: { ...row, checkedAt: now.toISOString() } }); + continue; + } + const options = { outbound: row.scope === "external" }; + if (budget.left < CALLS_PER_LINK || budget.outboundLeft < worstOutbound(options)) break; + + const result = await checkUrl(ctx, row.target, options); + budget.spend(result.requests, 0); + budget.spendOutbound(result.outbound); + if (host && result.retryAfter) backoff[host] = result.retryAfter; + + const previous = row.state === "pending" ? null : (row as LinkHistory); + const verdict = judge(previous, result, now, settings.failureThreshold); + updates.push({ + id, + data: + !verdict.conclusive && previous + ? { ...row, checkedAt: now.toISOString() } + : { + ...row, + state: verdict.state, + reason: result.reason, + status: result.status, + location: result.location, + finalUrl: result.finalUrl, + error: result.error, + consecutiveFailures: verdict.consecutiveFailures, + firstFailedAt: verdict.firstFailedAt, + reported: verdict.reported, + finding: findingFor(verdict), + checkedAt: now.toISOString(), + }, + }); + } + + if (updates.length > 0) { + await ctx.storage.checks.putMany(updates); + budget.spend(0, updates.length); + } + return { ...state, backoff: current(backoff) }; +} + +function chunk(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); + return chunks; +} + +function withoutFragment(url: string): string { + const parsed = new URL(url); + parsed.hash = ""; + return parsed.href; +} + +function hostOf(target: string): string { + try { + return new URL(target).host; + } catch { + return ""; + } +} + +function current(backoff: Record): Record { + const now = Date.now(); + return Object.fromEntries(Object.entries(backoff).filter(([, until]) => Date.parse(until) > now)); +} diff --git a/packages/content-link-check/tests/admin-route.test.ts b/packages/content-link-check/tests/admin-route.test.ts new file mode 100644 index 0000000..5c9a844 --- /dev/null +++ b/packages/content-link-check/tests/admin-route.test.ts @@ -0,0 +1,111 @@ +import { ROLE } from "@eisbachcode/emdash-plugin-shared"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * emdash validates every Block Kit response before the admin sees it, and + * rejects the whole page for one malformed block. Going through the real + * route is the only way to catch that. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +const admin = { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }; + +async function load(runtime: PluginRuntimeTestHost, page: string): Promise>> { + const response = await runtime.actions.routes.request("admin", { + method: "POST", + headers: { "x-emdash-request": "1" }, + body: { type: "page_load", page }, + user: admin, + }); + const body = (await response.json()) as { data?: { blocks?: unknown }; error?: { code: string } }; + if (!Array.isArray(body.data?.blocks)) throw new Error(`${page} was rejected: ${JSON.stringify(body.error)}`); + return body.data.blocks as Array>; +} + +describe("the admin surfaces", () => { + it("are accepted by emdash once there are results to show", async () => { + host = await createPluginRuntimeTestHost(); + await host.fixtures.site({ url: "https://example.test" }); + await host.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], + }); + await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/old-page" }, status: "published" }); + await host.http.respond( + "https://example.test/old-page", + new Response(null, { status: 301, headers: { location: "/new-page" } }), + ); + + const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + host.scheduled.setTime(`${night}T02:59:00.000Z`); + await host.actions.plugin.activate(); + host.scheduled.setTime(`${night}T03:00:05.000Z`); + for (let runs = 0; runs < 20; runs++) { + if ((await host.scheduled.run()).processed === 0) break; + } + + for (const page of ["widget:summary", "/report", "/settings"]) { + const blocks = await load(host, page); + expect(blocks.length).toBeGreaterThan(0); + } + expect((await load(host, "widget:summary")).some((block) => block.type === "stats")).toBe(true); + }); +}); + +describe("a plugin listed in the Astro config", () => { + // emdash never fires `plugin:activate` for it, so nothing here calls + // `actions.plugin.activate()`. + it("gets its nightly sweep from the first admin page load", async () => { + host = await createPluginRuntimeTestHost(); + await host.fixtures.site({ url: "https://example.test" }); + await host.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], + }); + await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/page" }, status: "published" }); + await host.http.respond("https://example.test/page", new Response(null, { status: 200 })); + + const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + host.scheduled.setTime(`${night}T02:59:00.000Z`); + await load(host, "widget:summary"); + host.scheduled.setTime(`${night}T03:00:05.000Z`); + for (let runs = 0; runs < 20; runs++) { + if ((await host.scheduled.run()).processed === 0) break; + } + + expect(await host.inspect.storage.list("checks")).toHaveLength(1); + }); + + it("gets its nightly sweep from a content change, with no admin page opened", async () => { + host = await createPluginRuntimeTestHost(); + await host.fixtures.site({ url: "https://example.test" }); + await host.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "cta_url", label: "Call to action", type: "string" }], + }); + const post = await host.fixtures.content("posts", { slug: "post", data: { cta_url: "/page" }, status: "draft" }); + await host.http.respond("https://example.test/page", new Response(null, { status: 200 })); + + const night = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10); + host.scheduled.setTime(`${night}T02:59:00.000Z`); + await host.actions.content.publish("posts", post.id); + // Content hooks run deferred, after the action has answered. + await vi.waitFor(async () => expect(await host?.inspect.scheduledTasks()).not.toEqual([])); + host.scheduled.setTime(`${night}T03:00:05.000Z`); + for (let runs = 0; runs < 20; runs++) { + if ((await host.scheduled.run()).processed === 0) break; + } + + expect(await host.inspect.storage.list("checks")).toHaveLength(1); + }); +}); diff --git a/packages/content-link-check/tests/bridge-calls.ts b/packages/content-link-check/tests/bridge-calls.ts new file mode 100644 index 0000000..6a22554 --- /dev/null +++ b/packages/content-link-check/tests/bridge-calls.ts @@ -0,0 +1,66 @@ +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[] = []; +/** For each call, the longest array it was given: ids for getMany/deleteMany, rows for putMany. */ +const sizes: number[] = []; +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); + sizes.push(Math.max(0, ...args.map((arg) => (Array.isArray(arg) ? arg.length : 0)))); + 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 { + return (await bridgeCallsWithSizes(invocation)).map(({ name }) => name); +} + +/** As `bridgeCalls`, with the longest array each call was given. */ +export async function bridgeCallsWithSizes( + invocation: () => Promise, +): Promise> { + install(); + calls.length = 0; + sizes.length = 0; + await invocation(); + return calls.map((name, i) => ({ name, size: sizes[i] ?? 0 })); +} + +/** + * 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-link-check/tests/budget.test.ts b/packages/content-link-check/tests/budget.test.ts new file mode 100644 index 0000000..eea2856 --- /dev/null +++ b/packages/content-link-check/tests/budget.test.ts @@ -0,0 +1,278 @@ +import { ROLE } from "@eisbachcode/emdash-plugin-shared"; +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SweepState } from "../src/state.js"; +import { bridgeCalls, bridgeCallsWithSizes } from "./bridge-calls.js"; + +/** + * The platform's limits, as of emdash 0.40 on Cloudflare. They are what the + * plugin must stay under, so they are written out here rather than taken + * from the plugin's own constants. + */ +/** Bridge calls a sandboxed invocation may make. */ +const SANDBOX_CALLS = 10; +/** Ids one storage call may bind: D1 allows 100 parameters and emdash's plugin storage adds two. */ +const D1_IDS = 98; +/** + * D1 queries a run may make in-process on Workers Free: 50 per invocation, + * less what emdash's cron runner spends claiming and finishing the task. + */ +const D1_QUERIES = 40; +/** Outbound subrequests an in-process invocation may make on Workers Free. */ +const OUTBOUND = 50; +/** emdash resolves each request's host over DNS-over-HTTPS (A and AAAA) before making it. */ +const SUBREQUESTS_PER_REQUEST = 3; +/** D1 queries of each bridge call, from emdash's implementation; `putMany` is one per row. */ +const QUERIES_OF: Record = { contentList: 3, cronSchedule: 2, schemaListCollections: 2 }; + +/** + * Every invocation against the bridge-call budget. + * + * A sandboxed invocation that makes one call too many is aborted on + * Cloudflare, and the local harness enforces no limit, so these count the + * calls. The sweep tests measure every run of whole sweeps rather than one + * hand-picked run per phase, and check the sweep finished, so a fixture + * too small to reach a phase's worst case cannot pass quietly. + */ + +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +const ENTRIES = 30; +const FIXTURE_HEAVY_MS = 60_000; +const admin = { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }; + +function night(daysAhead: number, time: string): string { + const day = new Date(Date.now() + daysAhead * 86_400_000).toISOString().slice(0, 10); + return `${day}T${time}.000Z`; +} + +/** + * Answers for one night of checks. Each entry links to its own internal + * page, an outbound page whose HEAD is refused (so the check spends a GET + * too), an outbound page that redirects once and one that redirects five + * times. One shared host rate-limits. + */ +async function answerOneNight(runtime: PluginRuntimeTestHost): Promise { + for (let i = 0; i < ENTRIES; i++) { + await runtime.http.respond(`https://example.test/page-${i}`, new Response(null, { status: 200 })); + await runtime.http.respond(`https://guarded${i}.test/`, new Response(null, { status: 403 })); + await runtime.http.respond(`https://guarded${i}.test/`, new Response(null, { status: 200 })); + await runtime.http.respond( + `https://moved${i}.test/`, + new Response(null, { status: 301, headers: { location: `https://landed${i}.test/` } }), + ); + await runtime.http.respond(`https://landed${i}.test/`, new Response(null, { status: 200 })); + // Five redirects, as many as emdash follows. + for (let hop = 1; hop <= 5; hop++) { + await runtime.http.respond( + `https://chain${i}.test/${hop}`, + new Response(null, { status: 302, headers: { location: `/${hop + 1}` } }), + ); + } + await runtime.http.respond(`https://chain${i}.test/6`, new Response(null, { status: 200 })); + } + await runtime.http.respond( + "https://busy.test/a", + new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }), + ); + await runtime.http.respond( + "https://busy.test/b", + new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }), + ); +} + +async function site(): Promise<{ runtime: PluginRuntimeTestHost; ids: string[] }> { + const runtime = await createPluginRuntimeTestHost(); + await runtime.fixtures.site({ url: "https://example.test" }); + await runtime.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: ["cta_url", "guarded_url", "moved_url", "chain_url", "busy_url"].map((slug) => ({ + slug, + label: slug, + type: "string" as const, + })), + }); + const ids: string[] = []; + for (let i = 0; i < ENTRIES; i++) { + const entry = await runtime.fixtures.content("posts", { + slug: `post-${i}`, + status: "published", + data: { + cta_url: `/page-${i}`, + guarded_url: `https://guarded${i}.test/`, + moved_url: `https://moved${i}.test/`, + chain_url: `https://chain${i}.test/1`, + busy_url: i % 2 === 0 ? "https://busy.test/a" : "https://busy.test/b", + }, + }); + ids.push(entry.id); + } + return { runtime, ids }; +} + +interface Run { + calls: Array<{ name: string; size: number }>; + /** Requests the plugin's fetches made, every redirect hop included. */ + requests: number; +} + +/** Run the cron executor until nothing is due, returning what every run did. */ +async function sweepRuns(runtime: PluginRuntimeTestHost): Promise { + const runs: Run[] = []; + for (let i = 0; i < 600; i++) { + let processed = 0; + const before = runtime.http.requests().length; + const calls = await bridgeCallsWithSizes(async () => { + processed = (await runtime.scheduled.run()).processed; + }); + if (processed === 0) return runs; + runs.push({ calls, requests: runtime.http.requests().length - before }); + } + throw new Error("the sweep did not end"); +} + +/** Every run that breaks one of the platform's limits, described. */ +function over(runs: Run[]): string[] { + return runs.flatMap(({ calls, requests }) => { + const names = calls.map(({ name }) => name); + const queries = calls.reduce( + (sum, { name, size }) => sum + (name === "storagePutMany" ? size : (QUERIES_OF[name] ?? 1)), + 0, + ); + const widestIn = Math.max( + 0, + ...calls.filter(({ name }) => name === "storageGetMany" || name === "storageDeleteMany").map(({ size }) => size), + ); + const outbound = requests * SUBREQUESTS_PER_REQUEST; + const broken = [ + names.length > SANDBOX_CALLS && `${names.length} bridge calls`, + queries > D1_QUERIES && `${queries} D1 queries`, + widestIn > D1_IDS && `${widestIn} ids in one call`, + outbound > OUTBOUND && `${outbound} outbound subrequests`, + ].filter(Boolean); + return broken.length > 0 ? [`${broken.join(", ")}: ${names.join(", ")}`] : []; + }); +} + +describe("a sweep", () => { + it( + "never makes a run spend more than the budget, first sweep and a pruning one alike", + async () => { + const { runtime, ids } = await site(); + host = runtime; + + await answerOneNight(runtime); + runtime.scheduled.setTime(night(1, "02:59:00")); + await runtime.actions.plugin.activate(); + runtime.scheduled.setTime(night(1, "03:00:05")); + const first = await sweepRuns(runtime); + expect(over(first)).toEqual([]); + expect((await runtime.inspect.kv.get("state"))?.phase).toBe("done"); + // Four links of each entry's own, and two busy.test links they share. + expect(await runtime.inspect.storage.list("checks")).toHaveLength(ENTRIES * 4 + 2); + + // More than one delete's worth, so the prune's id cap is exercised. + for (const id of ids.slice(0, 20)) await runtime.actions.content.unpublish("posts", id); + await answerOneNight(runtime); + runtime.scheduled.setTime(night(2, "03:00:05")); + const second = await sweepRuns(runtime); + expect(over(second)).toEqual([]); + expect((await runtime.inspect.kv.get("state"))?.phase).toBe("done"); + expect(await runtime.inspect.storage.list("checks")).toHaveLength((ENTRIES - 20) * 4 + 2); + }, + FIXTURE_HEAVY_MS, + ); +}); + +describe("the admin and the hooks", () => { + /** A config install: never activated, so the first surface also has to register the sweep. */ + async function configInstall(): Promise { + const runtime = await createPluginRuntimeTestHost(); + await runtime.fixtures.site({ url: "https://example.test" }); + await runtime.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "cta_url", label: "cta", type: "string" }], + }); + // One link in enough entries that the report has a long list of places to summarise. + for (let i = 0; i < 250; i++) { + await runtime.fixtures.content("posts", { slug: `post-${i}`, status: "published", data: { cta_url: "/old" } }); + } + await runtime.http.respond( + "https://example.test/old", + new Response(null, { status: 301, headers: { location: "/new" } }), + ); + return runtime; + } + + function admin_(runtime: PluginRuntimeTestHost, body: Record) { + return () => + runtime.actions.routes.request("admin", { + method: "POST", + headers: { "x-emdash-request": "1" }, + body, + user: admin, + }); + } + + it( + "stay within the budget on every surface, each at its worst", + async () => { + host = await configInstall(); + const surfaces = { + widget: admin_(host, { type: "page_load", page: "widget:summary" }), + report: admin_(host, { type: "page_load", page: "/report" }), + settings: admin_(host, { type: "page_load", page: "/settings" }), + }; + + // Each surface first on a fresh isolate's worth of state: the sweep + // is not registered yet, so the check has to schedule it too. + const firstLoad = await bridgeCalls(surfaces.widget); + expect(firstLoad, firstLoad.join(", ")).toContain("cronSchedule"); + expect(firstLoad.length, firstLoad.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); + + const night1 = night(1, "03:00:05"); + host.scheduled.setTime(night1); + await sweepRuns(host); + + for (const [name, load] of Object.entries(surfaces)) { + const calls = await bridgeCalls(load); + expect(calls.length, `${name}: ${calls.join(", ")}`).toBeLessThanOrEqual(SANDBOX_CALLS); + } + + const save = await bridgeCalls( + admin_(host, { type: "form_submit", action_id: "save_settings", values: { schedule: "0 4 * * *" } }), + ); + expect(save.length, save.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); + + const sweepNow = await bridgeCalls(admin_(host, { type: "block_action", action_id: "sweep_now" })); + expect(sweepNow.length, sweepNow.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); + + const status = await bridgeCalls(() => host!.actions.routes.request("status", { user: admin })); + expect(status.length, status.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); + }, + FIXTURE_HEAVY_MS, + ); + + it("stay within the budget in a content hook that has to register the sweep", async () => { + host = await createPluginRuntimeTestHost(); + await host.fixtures.site({ url: "https://example.test" }); + await host.fixtures.collection({ slug: "posts", label: "Posts" }); + const draft = await host.fixtures.content("posts", { slug: "post", status: "draft", data: {} }); + + const calls = await bridgeCalls(async () => { + await host!.actions.content.publish("posts", draft.id); + // Content hooks run deferred, after the action has answered. + await vi.waitFor(async () => expect(await host?.inspect.scheduledTasks()).not.toEqual([])); + }); + expect(calls, calls.join(", ")).toContain("cronSchedule"); + expect(calls.length, calls.join(", ")).toBeLessThanOrEqual(SANDBOX_CALLS); + }); +}); diff --git a/packages/content-link-check/tests/check.test.ts b/packages/content-link-check/tests/check.test.ts new file mode 100644 index 0000000..807a7d5 --- /dev/null +++ b/packages/content-link-check/tests/check.test.ts @@ -0,0 +1,364 @@ +import { createUnrestrictedHttpAccess } from "emdash"; +import type { PluginContext } from "emdash/plugin"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { checkUrl, judge, parseRetryAfter, type CheckResult, type Verdict } from "../src/check.js"; + +const INTERNAL = { outbound: false }; +const EXTERNAL = { outbound: true }; + +describe("checkUrl", () => { + it.each([ + [200, "ok", null], + [404, "broken", "not-found"], + [410, "broken", "not-found"], + [401, "unknown", "auth"], + [407, "unknown", "auth"], + [403, "unknown", "blocked"], + [451, "unknown", "blocked"], + [429, "unknown", "rate-limited"], + [400, "unknown", "unexpected-status"], + [500, "unknown", "server-error"], + [503, "unknown", "server-error"], + ])("calls a %i %s", async (status, state, reason) => { + const ctx = fakeCtx(async () => new Response(null, { status })); + expect(await checkUrl(ctx, "https://target.test/x", EXTERNAL)).toMatchObject({ state, reason, status }); + }); + + it("retries an outbound link with GET when HEAD fails", async () => { + const calls: string[] = []; + const ctx = fakeCtx(async (_url, init) => { + calls.push(String(init?.method)); + return new Response(null, { status: calls.length === 1 ? 403 : 200 }); + }); + + const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); + expect(calls).toEqual(["HEAD", "GET"]); + expect(result).toMatchObject({ state: "ok", requests: 2 }); + }); + + it("does not send a GET when HEAD succeeds", async () => { + const calls: string[] = []; + const ctx = fakeCtx(async (_url, init) => { + calls.push(String(init?.method)); + return new Response(null, { status: 200 }); + }); + + const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); + expect(calls).toEqual(["HEAD"]); + expect(result.requests).toBe(1); + }); + + it("charges three subrequests a request, and a followed redirect at its worst", async () => { + const plain = fakeCtx(async () => new Response(null, { status: 200 })); + expect((await checkUrl(plain, "https://target.test/x", EXTERNAL)).outbound).toBe(3); + + const refusedHead = fakeCtx(async (_url, init) => new Response(null, { status: init?.method === "HEAD" ? 403 : 200 })); + expect((await checkUrl(refusedHead, "https://target.test/x", EXTERNAL)).outbound).toBe(6); + + const followed = fakeCtx(async () => landed(new Response(null, { status: 200 }), "https://elsewhere.test/")); + expect((await checkUrl(followed, "https://target.test/x", EXTERNAL)).outbound).toBe(18); + + const failing = fakeCtx(async () => { + throw new Error("connection reset"); + }); + expect((await checkUrl(failing, "https://target.test/x", EXTERNAL)).outbound).toBe(18); + expect((await checkUrl(failing, "https://example.test/page", INTERNAL)).outbound).toBe(3); + }); + + it("does not ask a rate-limited host again with GET", async () => { + const calls: string[] = []; + const ctx = fakeCtx(async (_url, init) => { + calls.push(String(init?.method)); + return new Response(null, { status: 429 }); + }); + + await checkUrl(ctx, "https://target.test/x", EXTERNAL); + expect(calls).toEqual(["HEAD"]); + }); + + it("retries an internal link with GET only when HEAD is refused", async () => { + const methodsFor = async (status: number) => { + const calls: string[] = []; + const ctx = fakeCtx(async (_url, init) => { + calls.push(String(init?.method)); + return new Response(null, { status: calls.length === 1 ? status : 200 }); + }); + await checkUrl(ctx, "https://example.test/page", INTERNAL); + return calls; + }; + + expect(await methodsFor(404)).toEqual(["HEAD"]); + expect(await methodsFor(405)).toEqual(["HEAD", "GET"]); + }); + + it("keeps where an outbound link landed when HEAD redirected and GET finished the check", async () => { + const requested: string[] = []; + const ctx = fakeCtx(async (url, init) => { + requested.push(`${String(init?.method)} ${url}`); + return init?.method === "HEAD" + ? landed(new Response(null, { status: 405 }), "https://b.test/y") + : new Response(null, { status: 200 }); + }); + + const result = await checkUrl(ctx, "http://a.test/x", EXTERNAL); + expect(requested).toEqual(["HEAD http://a.test/x", "GET https://b.test/y"]); + expect(result).toMatchObject({ state: "ok", finalUrl: "https://b.test/y" }); + }); + + it("reports an internal redirect hop instead of following it", async () => { + const ctx = fakeCtx( + async () => new Response(null, { status: 301, headers: { location: "https://target.test/new" } }), + ); + const result = await checkUrl(ctx, "https://target.test/old", INTERNAL); + expect(result).toMatchObject({ state: "redirect", status: 301, location: "https://target.test/new" }); + }); + + it("keeps the time a rate-limited target asked for", async () => { + const ctx = fakeCtx(async () => new Response(null, { status: 429, headers: { "retry-after": "3600" } })); + const before = Date.now(); + const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); + const retryAt = Date.parse(result.retryAfter ?? ""); + expect(retryAt).toBeGreaterThanOrEqual(before + 3600_000); + expect(retryAt).toBeLessThanOrEqual(Date.now() + 3600_000); + }); + + it("turns a network error into an unresolved result", async () => { + const ctx = fakeCtx(async () => { + throw new Error("dns failure"); + }); + const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); + expect(result).toMatchObject({ state: "unknown", reason: "network-error", error: "dns failure" }); + }); + + it("recognises the sandbox bridge's redirect limit", async () => { + const ctx = fakeCtx(async () => { + throw new Error("Too many redirects (max 5)"); + }); + const result = await checkUrl(ctx, "https://target.test/x", EXTERNAL); + expect(result).toMatchObject({ state: "unknown", reason: "too-many-redirects" }); + }); + + describe("through emdash's own guarded fetch", () => { + // emdash resolves every target over DNS-over-HTTPS before it fetches, + // with the global fetch, and follows redirects itself. Stubbing the DNS + // answer runs the real guard and redirect loop, so a changed upstream + // error message or redirect behaviour fails here instead of in + // production. + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function stubDns(status: number, address?: string) { + vi.stubGlobal("fetch", async (input: string | URL | Request) => { + const type = new URL(input instanceof Request ? input.url : String(input)).searchParams.get("type"); + const answer = address && type === "A" ? [{ type: 1, data: address }] : []; + return Response.json({ Status: status, Answer: answer }); + }); + } + + function guardedCtx(targets: Record Response>) { + const http = createUnrestrictedHttpAccess("content-link-check", async (input) => { + const url = input instanceof Request ? input.url : String(input); + const target = targets[url]; + if (!target) throw new Error(`unexpected request to ${url}`); + return target(); + }); + return { http } as unknown as PluginContext; + } + + it("reports a domain that no longer exists as broken", async () => { + stubDns(3); + const result = await checkUrl(guardedCtx({}), "https://gone.test/page", EXTERNAL); + expect(result).toMatchObject({ state: "broken", reason: "dead-domain" }); + }); + + it("leaves a failed DNS lookup unresolved", async () => { + stubDns(2); + const result = await checkUrl(guardedCtx({}), "https://flaky.test/page", EXTERNAL); + expect(result.state).toBe("unknown"); + }); + + it("judges an outbound link by where its redirects end", async () => { + stubDns(0, "93.184.216.34"); + const ctx = guardedCtx({ + "https://old.test/": () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }), + "https://new.test/": () => new Response(null, { status: 200 }), + }); + const result = await checkUrl(ctx, "https://old.test/", EXTERNAL); + expect(result).toMatchObject({ state: "ok", finalUrl: "https://new.test/" }); + }); + + it("calls an outbound link broken when its redirect ends in a 404", async () => { + stubDns(0, "93.184.216.34"); + const ctx = guardedCtx({ + "https://old.test/": () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }), + "https://new.test/": () => new Response(null, { status: 404 }), + }); + const result = await checkUrl(ctx, "https://old.test/", EXTERNAL); + expect(result).toMatchObject({ state: "broken", reason: "not-found" }); + }); + + it("leaves an endless redirect unresolved", async () => { + stubDns(0, "93.184.216.34"); + const ctx = guardedCtx({ + "https://loop.test/a": () => new Response(null, { status: 302, headers: { location: "/b" } }), + "https://loop.test/b": () => new Response(null, { status: 302, headers: { location: "/a" } }), + }); + const result = await checkUrl(ctx, "https://loop.test/a", EXTERNAL); + expect(result).toMatchObject({ state: "unknown", reason: "too-many-redirects" }); + }); + }); + + it("rejects non-http schemes without a request", async () => { + let called = false; + const ctx = fakeCtx(async () => { + called = true; + return new Response(null, { status: 200 }); + }); + const result = await checkUrl(ctx, "mailto:hi@example.com", EXTERNAL); + expect(result.state).toBe("malformed"); + expect(called).toBe(false); + }); + + it("is unresolved rather than broken without the network capability", async () => { + const ctx = { http: undefined } as unknown as PluginContext; + expect((await checkUrl(ctx, "https://target.test/x", EXTERNAL)).state).toBe("unknown"); + }); +}); + +describe("parseRetryAfter", () => { + const now = Date.parse("2026-09-25T12:00:00.000Z"); + + it("reads delay-seconds and HTTP dates", () => { + expect(parseRetryAfter("120", now)).toBe("2026-09-25T12:02:00.000Z"); + expect(parseRetryAfter("Fri, 25 Sep 2026 14:00:00 GMT", now)).toBe("2026-09-25T14:00:00.000Z"); + }); + + it("holds the wait between a minute and a week", () => { + expect(parseRetryAfter("0", now)).toBe("2026-09-25T12:01:00.000Z"); + expect(parseRetryAfter("99999999", now)).toBe("2026-10-02T12:00:00.000Z"); + }); + + it("ignores a value it cannot read", () => { + expect(parseRetryAfter("soon", now)).toBeNull(); + expect(parseRetryAfter(null, now)).toBeNull(); + }); +}); + +describe("judge", () => { + const THRESHOLD = 2; + const start = new Date("2026-09-25T03:00:00.000Z"); + const hoursLater = (hours: number) => new Date(start.getTime() + hours * 3_600_000); + + /** Fold a series of checks, made at the given times, into one verdict. */ + function run(checks: Array<[CheckResult, Date]>): Verdict { + let verdict: Verdict | null = null; + for (const [result, at] of checks) verdict = judge(verdict, result, at, THRESHOLD); + if (!verdict) throw new Error("no checks"); + return verdict; + } + + it("does not report a 404 found twice within minutes", () => { + const verdict = run([ + [notFound, start], + [notFound, new Date(start.getTime() + 60_000)], + ]); + expect(verdict).toMatchObject({ state: "broken", consecutiveFailures: 2, reported: false }); + }); + + it("reports a 404 on the second nightly sweep", () => { + expect( + run([ + [notFound, start], + [notFound, hoursLater(24)], + ]).reported, + ).toBe(true); + }); + + it("reports a server error only once it has lasted, and only then calls it broken", () => { + const threeNights: Array<[CheckResult, Date]> = [ + [serverError, start], + [serverError, hoursLater(24)], + [serverError, hoursLater(48)], + ]; + expect(run(threeNights)).toMatchObject({ state: "unknown", reported: false }); + expect(run([...threeNights, [serverError, hoursLater(72)]])).toMatchObject({ state: "broken", reported: true }); + }); + + it("lets the last verdict stand on an answer that shows nothing", () => { + const verdict = run([ + [notFound, start], + [blocked, hoursLater(24)], + ]); + expect(verdict).toMatchObject({ + state: "broken", + consecutiveFailures: 1, + firstFailedAt: start.toISOString(), + reported: false, + conclusive: false, + }); + + const resumed = run([ + [notFound, start], + [blocked, hoursLater(24)], + [notFound, hoursLater(48)], + ]); + expect(resumed.reported).toBe(true); + }); + + it("calls a first check that shows nothing unresolved", () => { + expect(run([[blocked, start]])).toMatchObject({ state: "unknown", reported: false, conclusive: true }); + }); + + it("keeps a reported link reported through an answer that shows nothing", () => { + const verdict = run([ + [notFound, start], + [notFound, hoursLater(24)], + [blocked, hoursLater(48)], + ]); + expect(verdict).toMatchObject({ state: "broken", reported: true }); + }); + + it("keeps a reported 404 reported when its server starts failing", () => { + const verdict = run([ + [notFound, start], + [notFound, hoursLater(24)], + [serverError, hoursLater(48)], + ]); + expect(verdict).toMatchObject({ state: "broken", reported: true }); + }); + + it("clears the failure run when the link works again", () => { + const verdict = run([ + [notFound, start], + [ok, hoursLater(24)], + ]); + expect(verdict).toMatchObject({ state: "ok", consecutiveFailures: 0, firstFailedAt: null }); + }); +}); + +const ok: CheckResult = { + state: "ok", + reason: null, + status: 200, + location: null, + finalUrl: null, + retryAfter: null, + error: null, + requests: 1, + outbound: 3, +}; +const notFound: CheckResult = { ...ok, state: "broken", reason: "not-found", status: 404 }; +const serverError: CheckResult = { ...ok, state: "unknown", reason: "server-error", status: 503 }; +const blocked: CheckResult = { ...ok, state: "unknown", reason: "blocked", status: 403 }; + +/** A response as emdash hands it back after following redirects to `url`. */ +function landed(response: Response, url: string): Response { + Object.defineProperties(response, { url: { value: url }, redirected: { value: true } }); + return response; +} + +function fakeCtx(fetch: (url: string, init?: RequestInit) => Promise) { + return { http: { fetch } } as unknown as PluginContext; +} diff --git a/packages/content-link-check/tests/cron-names.test.ts b/packages/content-link-check/tests/cron-names.test.ts new file mode 100644 index 0000000..7d3bb88 --- /dev/null +++ b/packages/content-link-check/tests/cron-names.test.ts @@ -0,0 +1,80 @@ +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +/** + * The "Sweep now" button schedules a one-shot task under its own name, + * because `ctx.cron.schedule()` upserts on (plugin, task name): scheduling + * the recurring name with a one-shot timestamp would replace the nightly + * sweep instead of running once. So the name has to differ, and the cron + * handler has to answer to both. + */ + +/** Minimal context: a tick starts by reading plugin KV, then finds nothing to walk. */ +function fakeContext() { + const warns: string[] = []; + const reads: string[] = []; + const nothing = { items: [], cursor: undefined }; + const ctx = { + kv: { + async list() { + reads.push("kv.list"); + return []; + }, + async set() {}, + }, + schema: { listCollections: async () => [] }, + storage: { + checks: { query: async () => nothing }, + }, + log: { + debug() {}, + info() {}, + warn(message: string) { + warns.push(message); + }, + error() {}, + }, + } as unknown as PluginContext; + return { ctx, warns, reads }; +} + +function cronHandler() { + const entry = plugin.hooks?.cron; + if (!entry) throw new Error("plugin registers no cron hook"); + return typeof entry === "function" ? entry : entry.handler; +} + +async function run(name: string) { + const { ctx, warns, reads } = fakeContext(); + await cronHandler()({ name, scheduledAt: "2026-09-20T03:00:00.000Z" }, ctx); + return { warns, reads }; +} + +describe("cron task names", () => { + it("runs for the recurring sweep", async () => { + const { reads } = await run("sweep"); + expect(reads).not.toHaveLength(0); + }); + + it("runs for the one-shot the Sweep now button schedules", async () => { + // Without this the button schedules a task that fires and is + // discarded, so the button appears to do nothing at all. + const { reads } = await run("sweep-now"); + expect(reads).not.toHaveLength(0); + }); + + it("reaches the same code path for both names", async () => { + const recurring = await run("sweep"); + const oneShot = await run("sweep-now"); + expect(oneShot.warns).toEqual(recurring.warns); + expect(oneShot.reads).toEqual(recurring.reads); + }); + + it("ignores a task belonging to something else", async () => { + const { reads, warns } = await run("some-other-plugins-task"); + expect(reads).toHaveLength(0); + expect(warns).toHaveLength(0); + }); +}); diff --git a/packages/content-link-check/tests/discovery.test.ts b/packages/content-link-check/tests/discovery.test.ts new file mode 100644 index 0000000..b120ffe --- /dev/null +++ b/packages/content-link-check/tests/discovery.test.ts @@ -0,0 +1,63 @@ +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +/** + * A fresh install has no settings at all. The sweep has to find the site's + * collections itself, or the plugin sits idle until someone discovers a + * settings field and types the collection names in. + */ + +function fakeContext(collections: Record) { + const kv = new Map(); + const listed: Array<{ collection: string; status: unknown }> = []; + const noRows = { items: [], cursor: undefined }; + const ctx = { + kv: { + get: async (key: string) => kv.get(key) ?? null, + set: async (key: string, value: unknown) => void kv.set(key, value), + list: async () => [...kv].map(([key, value]) => ({ key, value })), + }, + schema: { + listCollections: async () => Object.keys(collections).map((slug) => ({ slug })), + }, + content: { + list: async (collection: string, options?: { where?: { status?: unknown } }) => { + listed.push({ collection, status: options?.where?.status }); + return { + items: (collections[collection] ?? []).map((id) => ({ id, status: "published", data: {} })), + cursor: undefined, + }; + }, + }, + storage: { + checks: { query: async () => noRows, getMany: async () => new Map(), putMany: async () => {} }, + }, + cron: { list: async () => [], schedule: async () => {} }, + site: { url: "https://example.test" }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + } as unknown as PluginContext; + return { ctx, listed }; +} + +async function tick(ctx: PluginContext, name: string) { + const entry = plugin.hooks?.cron; + if (!entry) throw new Error("plugin registers no cron hook"); + const handler = typeof entry === "function" ? entry : entry.handler; + await handler({ name, scheduledAt: "2026-09-24T03:00:00.000Z" }, ctx); +} + +describe("collection discovery", () => { + it("walks the published entries of every collection without any configuration", async () => { + const { ctx, listed } = fakeContext({ posts: ["p1", "p2"], pages: ["about"] }); + + await tick(ctx, "sweep"); + await tick(ctx, "sweep-next-a"); + + expect(listed).toEqual([ + { collection: "posts", status: "published" }, + { collection: "pages", status: "published" }, + ]); + }); +}); diff --git a/packages/content-link-check/tests/extract.test.ts b/packages/content-link-check/tests/extract.test.ts new file mode 100644 index 0000000..9c1e62b --- /dev/null +++ b/packages/content-link-check/tests/extract.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { classify, extractLinks, isRelativePath, toRequestUrl } from "../src/extract.js"; + +const SITE = "https://example.com"; + +describe("extractLinks", () => { + it("finds absolute URLs anywhere in the data", () => { + const links = extractLinks({ + title: "See https://external.test/docs for details", + meta: { nested: { deep: "http://other.test/page" } }, + }); + expect(links.map((l) => l.url)).toEqual(["https://external.test/docs", "http://other.test/page"]); + }); + + it("strips sentence punctuation from a URL in prose", () => { + const links = extractLinks({ title: "See https://external.test/docs, then stop." }); + expect(links.map((l) => l.url)).toEqual(["https://external.test/docs"]); + }); + + it("keeps a trailing slash or query that belongs to the URL", () => { + const links = extractLinks({ href: "https://external.test/docs/?q=1" }); + expect(links.map((l) => l.url)).toEqual(["https://external.test/docs/?q=1"]); + }); + + it("pulls hrefs out of portable text markDefs", () => { + const links = extractLinks({ + body: [ + { + _type: "block", + markDefs: [{ _key: "a1", _type: "link", href: "https://target.test/x" }], + children: [{ _type: "span", text: "click", marks: ["a1"] }], + }, + ], + }); + expect(links).toEqual([{ url: "https://target.test/x", path: "body[0].markDefs[0].href" }]); + }); + + it("takes relative paths only under link-ish keys", () => { + const links = extractLinks({ + href: "/about", + prose: "/not/a/link, just text with a slash", + }); + expect(links.map((l) => l.url)).toEqual(["/about"]); + }); + + it("takes relative paths from custom fields named after a link", () => { + // Field names from a real site whose internal links all went unseen. + const links = extractLinks({ + cta_url: "/contact", + hero_cta_url: "/en/contact", + services_items: [{ link_label: "Mehr", link_href: "/wordpress-zu-emdash" }], + ctaHref: "/about", + curl: "/not/a/link", + blurl: "/not/a/link/either", + }); + expect(links.map((l) => l.url)).toEqual(["/contact", "/en/contact", "/wordpress-zu-emdash", "/about"]); + }); + + it("deduplicates by url and keeps the first path", () => { + const links = extractLinks({ + a: { href: "/same" }, + b: { href: "/same" }, + }); + expect(links).toEqual([{ url: "/same", path: "a.href" }]); + }); + + it("ignores empty strings and respects the depth cap", () => { + expect(extractLinks({ href: " " })).toEqual([]); + const deep = { l1: { l2: { l3: { href: "https://too.deep/x" } } } }; + expect(extractLinks(deep, 2)).toEqual([]); + }); +}); + +describe("classify", () => { + it("treats same-origin absolute URLs as internal", () => { + expect(classify("https://example.com/a", SITE)).toBe("internal"); + expect(classify("/a", SITE)).toBe("internal"); + expect(classify("https://elsewhere.test/a", SITE)).toBe("external"); + }); + + it("does not crash on junk", () => { + expect(classify("not a url", SITE)).toBe("external"); + }); +}); + +describe("isRelativePath", () => { + it("rejects protocol-relative and bare slash", () => { + expect(isRelativePath("/about")).toBe(true); + expect(isRelativePath("//cdn.test/x")).toBe(false); + expect(isRelativePath("/")).toBe(false); + }); +}); + +describe("toRequestUrl", () => { + it("resolves relative paths against the site", () => { + expect(toRequestUrl("/about", SITE)).toBe("https://example.com/about"); + }); + + it("returns null for unparseable input", () => { + expect(toRequestUrl("http://[bad", SITE)).toBeNull(); + }); +}); diff --git a/packages/content-link-check/tests/full-sweep.test.ts b/packages/content-link-check/tests/full-sweep.test.ts new file mode 100644 index 0000000..2abc86f --- /dev/null +++ b/packages/content-link-check/tests/full-sweep.test.ts @@ -0,0 +1,279 @@ +import { createPluginRuntimeTestHost, type PluginRuntimeTestHost } from "@emdash-cms/plugin-test"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ROLE } from "@eisbachcode/emdash-plugin-shared"; + +import type { SweepState } from "../src/state.js"; +import type { CheckRow } from "../src/store.js"; + +/** + * One scheduled run does one bounded slice of work: plan a page of entries, + * or check a batch of them. A nightly schedule that ran one slice per night + * would take weeks to get through a site of any size, so a run that made + * progress has to hand over to a follow-up run. + * + * This goes through the real runtime and cron executor, because the trap is + * there: the executor deletes a finished one-shot task by id, so a follow-up + * scheduled under the running task's own name is silently thrown away. + */ + +const ENTRIES = 60; +/** Creating a few hundred entries outlasts vitest's default five seconds on a busy machine. */ +const FIXTURE_HEAVY_MS = 30_000; +let host: PluginRuntimeTestHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; +}); + +/** + * A time on the test clock, `daysAhead` days from today. Follow-up runs are + * timed by the plugin's clock, which is the real one, while the executor + * runs on the test clock, so a night has to lie ahead of the real clock or + * no follow-up is ever due. + */ +function night(daysAhead: number, time: string): string { + const day = new Date(Date.now() + daysAhead * 86_400_000).toISOString().slice(0, 10); + return `${day}T${time}.000Z`; +} + +/** Run the cron executor until nothing is due, as successive Cron Trigger firings would. */ +async function drain(runtime: PluginRuntimeTestHost, limit: number): Promise { + let runs = 0; + for (; runs < limit; runs++) { + const { processed } = await runtime.scheduled.run(); + if (processed === 0) break; + } + return runs; +} + +/** A site with one collection whose string fields are the given link fields. */ +async function site(fields: string[]): Promise { + const runtime = await createPluginRuntimeTestHost(); + await runtime.fixtures.site({ url: "https://example.test" }); + await runtime.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: fields.map((slug) => ({ slug, label: slug, type: "string" as const })), + }); + return runtime; +} + +function post(runtime: PluginRuntimeTestHost, slug: string, data: Record, status = "published") { + return runtime.fixtures.content("posts", { slug, data, status }); +} + +/** More runs than any sweep in this file needs, so a chain that never ends shows as reaching it. */ +const MAX_RUNS = 200; + +/** Activate the plugin and run the first nightly sweep to its end. */ +async function firstNight(runtime: PluginRuntimeTestHost): Promise { + runtime.scheduled.setTime(night(1, "02:59:00")); + await runtime.actions.plugin.activate(); + runtime.scheduled.setTime(night(1, "03:00:05")); + return drain(runtime, MAX_RUNS); +} + +async function nextNight(runtime: PluginRuntimeTestHost, daysAhead: number): Promise { + runtime.scheduled.setTime(night(daysAhead, "03:00:05")); + await drain(runtime, MAX_RUNS); +} + +async function checks(runtime: PluginRuntimeTestHost): Promise> { + return new Map((await runtime.inspect.storage.list("checks")).map(({ id, data }) => [id, data])); +} + +function requestsTo(runtime: PluginRuntimeTestHost, host: string): string[] { + return runtime.http + .requests() + .filter((request) => new URL(request.url).host === host) + .map((request) => `${request.method} ${request.url}`); +} + +const ok = () => new Response(null, { status: 200 }); + +describe("a scheduled sweep", () => { + it("checks every entry on the night it starts", async () => { + host = await site(["cta_url"]); + for (let i = 0; i < ENTRIES; i++) { + await post(host, `post-${i}`, { cta_url: `/page-${i}` }); + await host.http.respond(`https://example.test/page-${i}`, ok()); + } + + // The chain has to end by itself once the sweep is done. + expect(await firstNight(host)).toBeLessThan(MAX_RUNS); + + const stored = await checks(host); + expect(stored.size).toBe(ENTRIES); + expect([...stored.values()].every((check) => check.state === "ok")).toBe(true); + }); + + it("checks a link shared by several entries once", async () => { + host = await site(["source_url"]); + for (const slug of ["a", "b", "c"]) await post(host, slug, { source_url: "https://shared.test/page#part" }); + await host.http.respond("https://shared.test/page", ok()); + + await firstNight(host); + + expect(requestsTo(host, "shared.test")).toEqual(["HEAD https://shared.test/page"]); + expect((await checks(host)).get("https://shared.test/page")).toMatchObject({ state: "ok", placeCount: 3 }); + }); + + it("skips drafts, and forgets an entry's findings once it is unpublished", async () => { + host = await site(["cta_url", "source_url"]); + const live = await post(host, "live", { cta_url: "/old-page" }); + await post(host, "draft", { source_url: "https://draft.test/x" }, "draft"); + await host.http.respond( + "https://example.test/old-page", + new Response(null, { status: 301, headers: { location: "/new-page" } }), + ); + + await firstNight(host); + expect((await checks(host)).get("https://example.test/old-page")).toMatchObject({ finding: "redirect" }); + expect(requestsTo(host, "draft.test")).toEqual([]); + + await host.actions.content.unpublish("posts", live.id); + await nextNight(host, 2); + + expect(await checks(host)).toEqual(new Map()); + }); + + it("reports a finding however many working links were checked before it", async () => { + host = await site(["cta_url"]); + for (let i = 0; i < 101; i++) { + await post(host, `fine-${i}`, { cta_url: `/fine-${i}` }); + await host.http.respond(`https://example.test/fine-${i}`, ok()); + } + await post(host, "stale", { cta_url: "/old-page" }); + await host.http.respond( + "https://example.test/old-page", + new Response(null, { status: 301, headers: { location: "/new-page" } }), + ); + + await firstNight(host); + + expect((await reportRows(host)).map((row) => row.url)).toEqual(["/old-page"]); + }, FIXTURE_HEAVY_MS); + + it("keeps the report within emdash's limits when one link repeats across many entries", async () => { + host = await site(["cta_url"]); + for (let i = 0; i < 250; i++) await post(host, `post-${i}`, { cta_url: "/old-page" }); + await host.http.respond( + "https://example.test/old-page", + new Response(null, { status: 301, headers: { location: "/new-page" } }), + ); + + await firstNight(host); + + const rows = await reportRows(host); + expect(rows).toHaveLength(1); + expect(rows[0]?.entry).toMatch(/ \+249 more$/); + }, FIXTURE_HEAVY_MS); + + it("clears the findings of an entry unpublished while the plugin was switched off", async () => { + host = await site(["cta_url"]); + const live = await post(host, "live", { cta_url: "/old-page" }); + await host.http.respond( + "https://example.test/old-page", + new Response(null, { status: 301, headers: { location: "/new-page" } }), + ); + await firstNight(host); + expect((await checks(host)).size).toBe(1); + + await host.actions.plugin.deactivate(); + await host.actions.content.unpublish("posts", live.id); + await host.actions.plugin.activate(); + await nextNight(host, 2); + + expect(await checks(host)).toEqual(new Map()); + }); +}); + +describe("a requested sweep", () => { + it("starts afresh when requested while another sweep is under way", async () => { + host = await site(["cta_url"]); + for (let i = 0; i < 40; i++) { + await post(host, `post-${i}`, { cta_url: `/page-${i}` }); + // Once for the interrupted sweep, once for the one that replaces it. + await host.http.respond(`https://example.test/page-${i}`, ok()); + await host.http.respond(`https://example.test/page-${i}`, ok()); + } + host.scheduled.setTime(night(1, "02:59:00")); + await host.actions.plugin.activate(); + host.scheduled.setTime(night(1, "03:00:05")); + for (let i = 0; i < 4; i++) await host.scheduled.run(); + const interrupted = await host.inspect.kv.get("state"); + expect(interrupted?.phase).not.toBe("done"); + + await host.actions.routes.request("admin", { + method: "POST", + headers: { "x-emdash-request": "1" }, + body: { type: "block_action", action_id: "sweep_now" }, + user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }, + }); + await drain(host, MAX_RUNS); + + const finished = await host.inspect.kv.get("state"); + expect(finished?.phase).toBe("done"); + expect(finished?.startedAt && interrupted?.startedAt && finished.startedAt > interrupted.startedAt).toBe(true); + }); +}); + +describe("two nightly sweeps", () => { + it("follow an outbound redirect, report an internal one, and leave a rate-limited host alone", async () => { + host = await site(["cta_url", "source_url", "press_url", "docs_url"]); + await post(host, "post", { + cta_url: "/old-page", + source_url: "https://busy.test/report", + press_url: "https://moved.test/", + docs_url: "https://busy.test/docs", + }); + + const internalHop = () => new Response(null, { status: 301, headers: { location: "/new-page" } }); + const outboundHop = () => new Response(null, { status: 301, headers: { location: "https://new.test/" } }); + // Longer than both nights, on the test clock and the real one alike. + const rateLimited = () => new Response(null, { status: 429, headers: { "retry-after": String(7 * 86_400) } }); + + // Every hop needs an answer: an unanswered request hangs the sandboxed + // run instead of failing it. + await host.http.respond("https://example.test/old-page", internalHop()); + await host.http.respond("https://moved.test/", outboundHop()); + await host.http.respond("https://new.test/", ok()); + await host.http.respond("https://busy.test/report", rateLimited()); + await host.http.respond("https://busy.test/docs", rateLimited()); + await firstNight(host); + + await host.http.respond("https://example.test/old-page", internalHop()); + await host.http.respond("https://moved.test/", outboundHop()); + await host.http.respond("https://new.test/", ok()); + await nextNight(host, 2); + + const stored = await checks(host); + expect(stored.get("https://example.test/old-page")).toMatchObject({ state: "redirect", reported: true }); + expect(stored.get("https://moved.test/")).toMatchObject({ + state: "ok", + finalUrl: "https://new.test/", + reported: false, + }); + // Whichever busy.test link comes first is asked; the host's 429 spares the other. + const asked = requestsTo(host, "busy.test"); + expect(asked).toHaveLength(1); + const askedUrl = asked[0]?.split(" ")[1] ?? ""; + expect(stored.get(askedUrl)).toMatchObject({ reason: "rate-limited", reported: false }); + }); +}); + +/** The report table's rows, as the admin would receive them after emdash's Block Kit validation. */ +async function reportRows(runtime: PluginRuntimeTestHost): Promise>> { + const response = await runtime.actions.routes.request("admin", { + method: "POST", + headers: { "x-emdash-request": "1" }, + body: { type: "page_load", page: "/report" }, + user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN, createdAt: "2026-01-01" }, + }); + const body = (await response.json()) as { data?: { blocks?: unknown }; error?: unknown }; + if (!Array.isArray(body.data?.blocks)) throw new Error(`report rejected: ${JSON.stringify(body.error)}`); + const table = (body.data.blocks as Array>).find((block) => block.type === "table"); + return (table as { rows?: Array> } | undefined)?.rows ?? []; +} diff --git a/packages/content-link-check/tests/permissions.test.ts b/packages/content-link-check/tests/permissions.test.ts new file mode 100644 index 0000000..34d2ad8 --- /dev/null +++ b/packages/content-link-check/tests/permissions.test.ts @@ -0,0 +1,109 @@ +import { ROLE } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +/** + * The widget exists for editors, so the route has to say so: an `admin` + * route with no declared permission dispatches at `plugins:manage`, which is + * administrators only, and every editor gets a permission error inside the + * widget card instead of the report. + * + * One route carries one permission, and this one also serves the settings + * form, so the form is held to administrators in the handler. + */ + +function fakeContext() { + const writes: Array<[string, unknown]> = []; + const scheduled: string[] = []; + const ctx = { + kv: { + async get() { + return null; + }, + async set(key: string, value: unknown) { + writes.push([key, value]); + }, + }, + cron: { + async schedule(name: string) { + scheduled.push(name); + }, + }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + } as unknown as PluginContext; + return { ctx, writes, scheduled }; +} + +function adminHandler() { + const route = plugin.routes?.admin; + if (!route) throw new Error("plugin registers no admin route"); + return typeof route === "function" ? route : route.handler; +} + +async function saveSettingsAs(role: number | undefined) { + const { ctx, writes, scheduled } = fakeContext(); + const result = (await adminHandler()( + { + input: { type: "form_submit", action_id: "save_settings", values: { schedule: "0 5 * * *" } }, + request: { url: "https://example.test", method: "POST", headers: {} }, + ...(role === undefined ? {} : { user: { id: "u1", email: "u@example.test", name: null, role } }), + } as never, + ctx, + )) as { toast?: { message: string; type: string } }; + return { result, writes, scheduled }; +} + +describe("the admin route's permission", () => { + it("declares plugins:read so editors can open it", () => { + const route = plugin.routes?.admin; + expect(typeof route === "object" && route.permission).toBe("plugins:read"); + }); +}); + +describe("saving settings", () => { + it("is refused for an editor, with a reason", async () => { + const { result, writes, scheduled } = await saveSettingsAs(ROLE.EDITOR); + expect(writes).toHaveLength(0); + expect(scheduled).toHaveLength(0); + expect(result.toast?.type).toBe("error"); + expect(result.toast?.message).toMatch(/administrator/i); + }); + + it("is refused when there is no user at all", async () => { + const { result, writes } = await saveSettingsAs(undefined); + expect(writes).toHaveLength(0); + expect(result.toast?.type).toBe("error"); + }); + + it("goes through for an administrator", async () => { + const { result, writes, scheduled } = await saveSettingsAs(ROLE.ADMIN); + expect(writes.length).toBeGreaterThan(0); + // The cron is re-registered so a changed expression takes effect. + expect(scheduled).toContain("sweep"); + expect(result.toast).toBeUndefined(); + }); + + it("stores nothing when emdash refuses the schedule", async () => { + const { ctx, writes } = fakeContext(); + const refusing = { + ...ctx, + cron: { + async schedule() { + throw new Error("Invalid cron expression"); + }, + }, + } as unknown as PluginContext; + const result = (await adminHandler()( + { + input: { type: "form_submit", action_id: "save_settings", values: { schedule: "every night" } }, + request: { url: "https://example.test", method: "POST", headers: {} }, + user: { id: "u1", email: "u@example.test", name: null, role: ROLE.ADMIN }, + } as never, + refusing, + )) as { toast?: { message: string; type: string } }; + expect(writes).toHaveLength(0); + expect(result.toast?.type).toBe("error"); + }); +}); diff --git a/packages/content-link-check/tests/plugin.test.ts b/packages/content-link-check/tests/plugin.test.ts new file mode 100644 index 0000000..b1a6972 --- /dev/null +++ b/packages/content-link-check/tests/plugin.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import plugin from "../src/plugin.js"; + +/** + * Surface test. The build probes the default export for its hook and + * route keys, so a rename that the manifest does not know about is a + * silent behaviour loss — this catches it. + */ +describe("plugin surface", () => { + it("registers the hooks the sweep needs", () => { + expect(Object.keys(plugin.hooks ?? {}).sort()).toEqual([ + "content:afterPublish", + "content:afterSave", + "cron", + "plugin:activate", + ]); + }); + + it("registers the admin and status routes", () => { + expect(Object.keys(plugin.routes ?? {}).sort()).toEqual(["admin", "status"]); + }); +}); diff --git a/packages/content-link-check/tests/report.test.ts b/packages/content-link-check/tests/report.test.ts new file mode 100644 index 0000000..95518cb --- /dev/null +++ b/packages/content-link-check/tests/report.test.ts @@ -0,0 +1,103 @@ +import type { PluginContext } from "emdash/plugin"; +import { describe, expect, it } from "vitest"; + +import { buildReportPage, buildSettingsPage } from "../src/report.js"; +import { settingsFrom } from "../src/settings.js"; +import type { CheckRow, Place } from "../src/store.js"; + +function place(entrySlug: string | null, locale: string): Place { + return { + url: "/privacy-policy", + path: "content[45].markDefs[0].href", + collection: "pages", + entryId: `01M2RABGX6C6AYNDFBV542M7D5-${locale}`, + entrySlug, + locale, + }; +} + +function redirect(target: string, places: Place[], placeCount = places.length): CheckRow { + return { + target, + scope: "internal", + state: "redirect", + reason: null, + status: 301, + location: "/datenschutz", + finalUrl: null, + error: null, + consecutiveFailures: 0, + firstFailedAt: null, + reported: true, + finding: "redirect", + checkedAt: "2026-09-24T00:00:00.000Z", + firstSeenAt: "2026-09-24T00:00:00.000Z", + seenAt: "2026-09-24T00:00:00.000Z", + lastPage: "", + places, + placeCount, + }; +} + +/** Storage that answers every query with all its rows; the where clauses are emdash's to test. */ +function fakeContext(checks: CheckRow[]) { + return { + storage: { + checks: { + count: async () => checks.length, + query: async () => ({ items: checks.map((data) => ({ id: data.target, data })), cursor: null }), + }, + }, + } as unknown as PluginContext; +} + +function table(blocks: Array>) { + return blocks.find((block) => block.type === "table") as + | { columns: Array<{ key: string }>; rows: Array> } + | undefined; +} + +describe("the link report", () => { + it("names each finding's entry by its slug, and its language on a multilingual site", async () => { + const { blocks } = await buildReportPage( + fakeContext([ + redirect("https://example.test/datenschutz-alt", [place("datenschutz", "de")]), + redirect("https://example.test/privacy-old", [place("privacy", "en")]), + ]), + ); + const rows = table(blocks)?.rows ?? []; + expect(rows.map((row) => [row.entry, row.locale]).sort()).toEqual([ + ["pages/datenschutz", "de"], + ["pages/privacy", "en"], + ]); + }); + + it("lists a link once, however many entries it appears in", async () => { + const shared = redirect("https://example.test/privacy-policy", [place("datenschutz", "de"), place("privacy", "de")], 12); + const { blocks } = await buildReportPage(fakeContext([shared])); + expect(table(blocks)?.rows.map((row) => row.entry)).toEqual(["pages/datenschutz +11 more"]); + }); + + it("falls back to the id for an entry without a slug", async () => { + const { blocks } = await buildReportPage( + fakeContext([redirect("https://example.test/privacy-policy", [place(null, "de")])]), + ); + expect(table(blocks)?.rows[0]?.entry).toBe("pages/01M2RABGX6C6AYNDFBV542M7D5-de"); + expect(table(blocks)?.columns.map((column) => column.key)).not.toContain("locale"); + }); +}); + +describe("the settings form", () => { + it("shows the saved values, not blank fields", async () => { + // Block Kit inputs render `initial_value`. With anything else the outbound + // toggle showed as off while the plugin was still checking outbound links. + const { blocks } = buildSettingsPage(settingsFrom({ batchSize: 10 })); + const form = blocks.find((block) => block.type === "form") as { + fields: Array<{ action_id: string; initial_value?: unknown }>; + }; + const value = (id: string) => form.fields.find((field) => field.action_id === id)?.initial_value; + expect(value("batchSize")).toBe(10); + expect(value("checkExternal")).toBe(true); + expect(value("schedule")).toBe("0 3 * * *"); + }); +}); diff --git a/packages/content-link-check/tests/schedule.test.ts b/packages/content-link-check/tests/schedule.test.ts new file mode 100644 index 0000000..847e3f6 --- /dev/null +++ b/packages/content-link-check/tests/schedule.test.ts @@ -0,0 +1,76 @@ +import { ROLE } from "@eisbachcode/emdash-plugin-shared"; +import type { PluginContext, SandboxedPlugin } from "emdash/plugin"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The plugin remembers, per isolate, that it has seen its sweep in place. + * Each test loads a fresh copy so that memory starts empty. + */ +let plugin: SandboxedPlugin; + +beforeEach(async () => { + vi.resetModules(); + plugin = (await import("../src/plugin.js")).default; +}); + +interface FakeCron { + list(): Promise>; + schedule(name: string, opts: { schedule: string }): Promise; +} + +function fakeContext(cron: FakeCron, kv: Record = {}) { + return { + cron, + kv: { + get: async (key: string) => kv[key] ?? null, + list: async () => Object.entries(kv).map(([key, value]) => ({ key, value })), + }, + storage: { + checks: { count: async () => 0, query: async () => ({ items: [], cursor: null }) }, + }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + } as unknown as PluginContext; +} + +async function loadWidget(ctx: PluginContext) { + const route = plugin.routes?.admin; + if (!route || typeof route === "function") throw new Error("plugin registers no admin route object"); + return (await route.handler( + { + input: { type: "page_load", page: "widget:summary" }, + request: { url: "https://example.test", method: "POST", headers: {} }, + user: { id: "u1", email: "admin@example.test", name: null, role: ROLE.ADMIN }, + } as never, + ctx, + )) as { blocks?: unknown[] }; +} + +describe("the sweep schedule", () => { + it("is put back on the saved schedule when the registered task has drifted from it", async () => { + const scheduled: Array<[string, string]> = []; + const cron: FakeCron = { + list: async () => [{ name: "sweep", schedule: "0 3 * * *" }], + schedule: async (name, opts) => void scheduled.push([name, opts.schedule]), + }; + await loadWidget(fakeContext(cron, { settings: { schedule: "0 5 * * *" } })); + expect(scheduled).toEqual([["sweep", "0 5 * * *"]]); + }); + + it("is left alone when the registered task matches the saved schedule", async () => { + const scheduled: string[] = []; + const cron: FakeCron = { + list: async () => [{ name: "sweep", schedule: "0 3 * * *" }], + schedule: async (name) => void scheduled.push(name), + }; + await loadWidget(fakeContext(cron)); + expect(scheduled).toEqual([]); + }); + + it("never costs the admin the page when it cannot be checked", async () => { + const unavailable = async () => { + throw new Error("cron unavailable"); + }; + const result = await loadWidget(fakeContext({ list: unavailable, schedule: unavailable })); + expect(result.blocks?.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/content-link-check/tests/settings.test.ts b/packages/content-link-check/tests/settings.test.ts new file mode 100644 index 0000000..8dd5d1a --- /dev/null +++ b/packages/content-link-check/tests/settings.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SETTINGS, mergeSettings, readSettings } from "../src/settings.js"; + +describe("readSettings", () => { + it("returns defaults on an empty store", async () => { + expect(await readSettings(fakeKv({}))).toEqual(DEFAULT_SETTINGS); + }); + + it("reads stored values", async () => { + const settings = await readSettings(fakeKv({ settings: { batchSize: 10, checkExternal: false } })); + expect(settings).toMatchObject({ + batchSize: 10, + checkExternal: false, + schedule: DEFAULT_SETTINGS.schedule, + }); + }); +}); + +describe("mergeSettings", () => { + it("clamps on the way in and ignores unknown keys", () => { + const merged = mergeSettings(DEFAULT_SETTINGS, { batchSize: 9999, nonsense: true }); + expect(merged.batchSize).toBe(100); + expect(merged).not.toHaveProperty("nonsense"); + }); + + it("keeps what the form did not send", () => { + const current = { ...DEFAULT_SETTINGS, schedule: "0 5 * * *" }; + expect(mergeSettings(current, { checkExternal: false })).toMatchObject({ + schedule: "0 5 * * *", + checkExternal: false, + }); + }); +}); + +function fakeKv(store: Record) { + return { + kv: { + get: async (key: string) => store[key] ?? null, + list: async () => Object.entries(store).map(([key, value]) => ({ key, value })), + }, + } as unknown as import("emdash/plugin").PluginContext; +} diff --git a/packages/content-link-check/tsconfig.json b/packages/content-link-check/tsconfig.json new file mode 100644 index 0000000..8268866 --- /dev/null +++ b/packages/content-link-check/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*", + "tests/**/*", + "vitest.config.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/content-link-check/vitest.config.ts b/packages/content-link-check/vitest.config.ts new file mode 100644 index 0000000..856fcc5 --- /dev/null +++ b/packages/content-link-check/vitest.config.ts @@ -0,0 +1,19 @@ +import { emdashPluginTest } from "@emdash-cms/plugin-test/config"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [emdashPluginTest()], + test: { + // Most tests run the plugin inside workerd through the test host, which + // can pass vitest's 5 s default on a laptop and miss it on shared CI + // runners. The same limits as upstream's own plugin-test suite. + testTimeout: 30_000, + hookTimeout: 30_000, + // plugin-test 0.2 builds the plugin into this package's dist/ once + // per test file and reads dist/manifest.json back. Files run in + // parallel rewrite that file while others read it, and a reader that + // gets it half-written fails the whole file with "Unexpected end of + // JSON input". + fileParallelism: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304b5b6..90ace02 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.6.5) + 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) '@emdash-cms/plugin-test': specifier: 0.2.0 - version: 0.2.0(23e33028ac8753110cfb4cf3c9afb42f) + version: 0.2.0(f863f6b7fe5271f41d62dece9033c2f3) 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) @@ -49,13 +49,34 @@ 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.3 + version: 0.2.3(f791968eac255bb6d5c696c81a641535) + 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) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 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)) + + packages/content-link-check: + devDependencies: + '@eisbachcode/emdash-plugin-shared': + specifier: workspace:* + version: link:../shared '@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.1)(@emnapi/runtime@1.11.1) + 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.3 - version: 0.2.3(824e54279456c15d4fb280fbef81e1d3) + version: 0.2.3(f64bdd591a184e04878ebed7d065ed68) 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) @@ -70,7 +91,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))(@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) + 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) typescript: specifier: ^5.9.0 version: 5.9.3 @@ -4668,28 +4689,6 @@ 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)': @@ -5012,84 +5011,6 @@ 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 @@ -5187,9 +5108,9 @@ snapshots: - date-fns - zod - '@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/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)': dependencies: - '@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) + '@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 @@ -5203,7 +5124,7 @@ snapshots: - date-fns - zod - '@emdash-cms/cloudflare@0.39.0(4393bffb26115eecd490f095bbbbcb1e)': + '@emdash-cms/cloudflare@0.39.0(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 @@ -5216,7 +5137,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 @@ -5247,7 +5168,7 @@ snapshots: - typescript - utf-8-validate - '@emdash-cms/cloudflare@0.40.1(a7ee94a070d1e2ec5745e252646edfd0)': + '@emdash-cms/cloudflare@0.40.1(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 @@ -5260,7 +5181,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 @@ -5306,7 +5227,7 @@ 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)': + '@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) '@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) @@ -5325,7 +5246,7 @@ snapshots: 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) + tsdown: 0.20.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(typescript@6.0.3) typescript: 6.0.3 zod: 4.5.4 transitivePeerDependencies: @@ -5345,53 +5266,76 @@ snapshots: - 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)': + '@emdash-cms/plugin-test@0.2.0(f863f6b7fe5271f41d62dece9033c2f3)': 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 + '@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/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-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.3)(@emnapi/runtime@1.11.3)(typescript@6.0.3) - typescript: 6.0.3 - zod: 4.5.4 + 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) + kysely: 0.29.6 + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) + 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)) transitivePeerDependencies: - '@arethetypeswrong/core' + - '@astrojs/cloudflare' + - '@astrojs/react' - '@atcute/cbor' - '@atcute/cid' - '@atcute/identity' + - '@cfworker/json-schema' + - '@cloudflare/kumo' + - '@cloudflare/workers-types' + - '@date-fns/tz' + - '@emdash-cms/auth-atproto' - '@emnapi/core' - '@emnapi/runtime' + - '@emotion/is-prop-valid' + - '@floating-ui/dom' + - '@lingui/babel-plugin-lingui-macro' + - '@phosphor-icons/react' + - '@tiptap/extensions' - '@ts-macro/tsc' + - '@types/node' + - '@types/react' + - '@types/react-dom' - '@typescript/native-preview' - '@vitejs/devtools' + - '@vitest/runner' + - '@vitest/snapshot' + - astro + - babel-plugin-macros + - bufferutil + - date-fns + - echarts - oxc-resolver + - pg + - pg-native + - prosemirror-model + - prosemirror-state + - prosemirror-view - publint + - react + - react-dom + - supports-color - synckit + - typescript - unplugin-lightningcss - unplugin-unused + - utf-8-validate - vue-tsc + - wrangler + - zod - '@emdash-cms/plugin-test@0.2.0(23e33028ac8753110cfb4cf3c9afb42f)': + '@emdash-cms/plugin-test@0.2.3(f64bdd591a184e04878ebed7d065ed68)': 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.6.5) - '@emdash-cms/cloudflare': 0.39.0(4393bffb26115eecd490f095bbbbcb1e) + '@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/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) + 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 vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2)(yaml@2.9.1) 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)) @@ -5446,12 +5390,12 @@ snapshots: - wrangler - zod - '@emdash-cms/plugin-test@0.2.3(824e54279456c15d4fb280fbef81e1d3)': + '@emdash-cms/plugin-test@0.2.3(f791968eac255bb6d5c696c81a641535)': 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.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/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/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 @@ -6268,14 +6212,6 @@ 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) @@ -7416,94 +7352,6 @@ 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: {} @@ -8388,23 +8236,6 @@ 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 @@ -8422,7 +8253,7 @@ snapshots: 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@6.0.3): + rolldown-plugin-dts@0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3): dependencies: '@babel/generator': 8.0.0-rc.2 '@babel/helper-validator-identifier': 8.0.0-rc.2 @@ -8460,28 +8291,6 @@ 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 @@ -8752,35 +8561,6 @@ 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 @@ -8821,7 +8601,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(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3))(typescript@6.0.3) + rolldown-plugin-dts: 0.22.5(rolldown@1.0.0-rc.3)(typescript@6.0.3) semver: 7.8.5 tinyexec: 1.3.1 tinyglobby: 0.2.17