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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/content-link-check/.agents/skills
1 change: 1 addition & 0 deletions packages/content-link-check/.claude/CLAUDE.md
1 change: 1 addition & 0 deletions packages/content-link-check/.claude/skills
2 changes: 2 additions & 0 deletions packages/content-link-check/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
14 changes: 14 additions & 0 deletions packages/content-link-check/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
123 changes: 123 additions & 0 deletions packages/content-link-check/README.md
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions packages/content-link-check/emdash-plugin.jsonc
Original file line number Diff line number Diff line change
@@ -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" }],
},
}
67 changes: 67 additions & 0 deletions packages/content-link-check/package.json
Original file line number Diff line number Diff line change
@@ -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 <daniel@eisbachcode.de>",
"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"
}
}
29 changes: 29 additions & 0 deletions packages/content-link-check/skills/creating-plugins/SKILL.md
Original file line number Diff line number Diff line change
@@ -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/.
62 changes: 62 additions & 0 deletions packages/content-link-check/src/budget.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading