This is an engineering design document — it describes how colophon works inside, and may run ahead of (or behind) the shipped code. The user-facing guides are the source of truth for behaviour.
Status: built · relates to PLAN §10 (Federation & IndieWeb), §6 (build pipeline). The whole design ships: the
<link rel="webmention">tag;webmention send(sent-cache);webmention fetch(jf2 reader →_mentions/cache); per-sitedisplay.mode(live/asset/disabled) with the themed responses block +mentions.js;webmention publish(decoupled_mentions/-only deploy); and the committed glob blocklist +colophon-moderate-mentionsskill. Code ininternal/webmention/,internal/build/{mentions,webmention}.go,internal/cli/webmention.go. Deferred: semantic moderation and searchable mentions (see those sections).
Goal: let a colophon site participate in Webmention — the W3C standard for "site A notified site B that it linked to / replied to / liked B's post" — while staying fully static (no server, no database) and keeping received data in clean, separate assets rather than mixed into the author's content. Two halves: sending (we tell others we linked them) and receiving + display (others' mentions appear under our posts).
It deliberately reuses five patterns colophon already has, rather than inventing new ones — see Fit with existing design.
Why a separate command (not part of build)
The two operations run at different points in the lifecycle, so neither belongs inside build:
| Operation | When | Why |
|---|---|---|
colophon webmention send |
after publish |
the source URL must be live so the receiver can fetch it back and verify the link |
colophon webmention fetch |
before build, or standalone/scheduled |
pulls received mentions into the local cache so a bake build can read them |
colophon webmention publish |
on its own cadence (e.g. cron) | fetch + push only the _mentions/ prefix to the object store, decoupled from the site build — see Separate publish pipeline |
So webmention is a command group alongside build/publish. A typical CI flow:
colophon webmention fetch # refresh received mentions → committed cache
colophon build # emit _mentions/ assets (+ bake for no-JS themes)
colophon publish --env production --allow-publish
colophon webmention send # now that the new post is live, notify the sites it links to
fetch is also fine to run on a schedule (cron) to keep mentions fresh without a content change.
Receiving: data as a separate asset
We don't run a server, so we can't accept inbound POSTs. Mentions are received by a hosted
receiver — webmention.io (free; the page advertises it via
<link rel="webmention">). fetch reads them back through its JSON API (token in {env:…},
never config) and normalises them into one JSON file per post, served as its own asset
namespace — exactly mirroring the _search/ index:
_mentions/<post-path>.json e.g. _mentions/posts/hello-world.json
Each file is a small, normalised list (not the raw webmention.io payload):
{
"target": "https://blog.example.com/posts/hello-world/",
"updated": "2026-06-22T10:00:00Z",
"mentions": [
{ "type": "like", "author": {"name": "Ada", "url": "https://ada.example", "photo": "https://…/ada.jpg"},
"url": "https://ada.example/likes/1", "published": "2026-06-21T09:00:00Z" },
{ "type": "reply", "author": {"name": "Bob", "url": "https://bob.example", "photo": "…"},
"url": "https://bob.example/notes/2", "published": "…", "content": "Nice post!" }
]
}
typeis normalised tolike | repost | reply | mention(from the sender's mf2 / wm-property).- author fields come from the sender's
h-card— which is why mf2 shipped first. - The build emits these to the output tree under
_mentions/, routed to R2 like_search/, and they get CORS for free frompublish --create(sameGET/HEADrule the JS search index already relies on for cross-origin fetch). fetchwrites a local cache (.colophon/cache/webmentions/). This cache is not treated like the generated-image cache — see below.
One JSON-per-post (no sharded manifest like search) because a post page already knows its own key.
Not reproducible — and that's fine
Unlike the gen-image cache (a deterministic function of the prompt, committed for reproducible
builds), received mentions are external, time-varying state — other people's posts. The local
.colophon/cache/webmentions/ is therefore a derived export, not preserved state: webmention.io
is the source of truth and fetch fully regenerates the export from it every run.
fetch queries the whole domain — GET /api/mentions.jf2?domain=<domain>&token={env:…} returns
the complete current set (JF2, newest-first), paged via per-page+page — then buckets by
target and writes one JSON per post, replacing whatever was there. Properties of that:
- Stateless / idempotent. A fresh CI runner with an empty cache just rebuilds it from the API (needs only network + token). "Empty" means "rebuild it," never "lost data."
- Self-reconciling. Because each run takes the full current set, deleted/edited mentions
correct themselves — no stale entries to prune. (
since/since_idenable delta fetches later if volume ever warrants; full-regenerate is the simple default.) - Graceful when empty. Until
fetchruns, a missing_mentions/<post>.jsonrenders nothing and never fails the build — mentions are always additive chrome. - The JS path removes the build dependency entirely. A JS-rendering theme ships only a
placeholder; the browser fetches the separately-published
_mentions/from R2 (see Separate publish pipeline), refreshed out-of-band.
So committing the JSON to the repo is optional (it only helps a baked build show mentions with no
network, at the cost of churny commits); the default is "regenerated by fetch in CI / on a
schedule," not "committed."
Deletions & pruning (full-replace, not merge)
"Self-reconciling" only holds if regenerate is a full replace of the _mentions/ namespace,
not a per-post upsert — otherwise a mention webmention.io has deleted lingers in a stale file.
Concretely:
- A deleted mention isn't in the next
fetchresult, so the rewritten per-post file omits it. - Zero-mentions edge: when a post loses all its mentions there's nothing to write for it.
fetchmust therefore clear the namespace and rewrite wholesale so that post ends up with no file (not a leftover from a prior run), andpublishmust prune objects no longer present from the store — which the existing incremental publisher already does (thePruner: "only changed files upload, orphans are pruned"). The stale object is deleted from R2. - The JS path then 404s → renders nothing; a baked rebuild finds no file → renders nothing.
- This is eventually consistent: a deletion persists until the next
fetch+publish(JS) or rebuild (bake). The scheduledwebmention publishkeeps that window short without a site build.
Implication for publish: it must apply orphan pruning scoped to the _mentions/ prefix so a
mentions refresh never deletes content objects (and vice versa).
Display: a per-site mode (live / asset / disabled)
How responses reach the page is a per-site setting (federation.indieweb.webmention.display.mode).
There are exactly three values — there is no separate "baked" mode (baking is a theme choice
within asset, see below). The mode decides where the browser fetches from and whether the
engine ships anything at all; both active modes use the same mentions.js + the same
moderation pipeline.
| Mode | What ships per page | Browser fetches from | Build-time data? | Freshness | Privacy |
|---|---|---|---|---|---|
live |
placeholder + JS | the receiver directly | no | next page load | visitor hits the receiver |
asset |
placeholder + JS (+ optional bake) | our _mentions/ on R2 |
yes (synced list) | the refresh cron | self-hosted |
disabled |
nothing (zero counts) | — | — | — | — |
Per-page toggle. When the mode is active (live/asset), webmentions are on by default for
every post, opt-out per page via frontmatter (webmentions: false). In disabled the page setting
is ignored — nothing is embedded or shipped anywhere, has_mentions is false and mentions is empty.
live — JS straight to the receiver (no fetch/publish/build)
The engine ships, on each enabled page, an engine-provided placeholder + the shared mentions.js;
the browser calls the receiver's read API directly (webmention.io exposes a public, CORS-enabled,
token-free read endpoint). A new mention shows on the next page load — no fetch, no publish,
no rebuild, no asset to host. Lowest-infra, most realtime.
Crucially, in live the engine has no build-time data — so has_mentions/count are not known
at build. The placeholder ships whenever the page is enabled (not gated on a count), and JS fills
in the count (and hides the section if it resolves to zero). So themes cannot bake in live mode.
Because a reader speaks a specific read API, the JS is parameterised by the reader driver: the
driver declares a small client descriptor (endpoint URL template, query params, and the mapping
from its response → our normalised shape). One shared mentions.js consumes the descriptor and
handles any JF2-shaped endpoint (webmention.io and compatibles — all one jf2 driver); a service
with an exotic API would be a different driver shipping its own client module. So "the fetch JS is
provided by the specific driver" — yes, via the descriptor, without forking the renderer per service.
Moderation still applies: the distilled glob blocklist is shipped to the client (it's
spam-hiding, not a secret) and filtered in-browser. Semantic rules can't run client-side (no
embeddings in the browser), so a site that needs semantic moderation should use asset. Trade-offs
to accept: every visitor's browser hits a third party (their uptime/rate-limits become yours; a
privacy leak), and no-JS/RSS readers see nothing.
asset — JS against our published _mentions/, with optional build-time bake
The default is the same placeholder + mentions.js, but pointed at our server-curated
_mentions/<key>.json on R2. Freshness comes from a scheduled webmention publish — fetch +
push of only the _mentions/ prefix, no site build (see
Separate publish pipeline) — near-realtime to whatever cron cadence you
pick, with full server-side moderation (glob + semantic), self-hosting, and driver-neutrality.
Because the synced mentions list already exists at build time in this mode, the engine also exposes
it to the template (mentions/mentions_html/has_mentions) so a theme that wants to
hard-code / bake them into the HTML can — instead of, or alongside, the JS placeholder. This is
plausible precisely because we already have the built/synced list; it's a theme capability, not a
mode. Baked output is then as-of-last-build (the JS placeholder is what stays cron-fresh), and it's
the no-JS escape hatch for text-first themes like minimal.
Template surface
Rendering remains a template responsibility — the engine only exposes the data, exactly as
attachments/attachments_html do:
| Var | live |
asset |
disabled |
|---|---|---|---|
has_mentions |
unknown at build → false (JS fills count) |
accurate (from synced list) | false |
mentions |
empty (no build-time data) | structured [{type, author{name,url,photo}, url, content, published}] — bake your own |
empty |
mentions_html |
empty | engine-rendered drop-in block (empty when none) | empty |
mentions_src |
driver client-descriptor endpoint | our _mentions/<key>.json |
unset |
mentions_enabled |
true unless page opted out |
true unless page opted out |
false |
Bundled themes drop the placeholder + mentions.js for the JS modes; minimal (no-JS) uses
asset + the baked mentions_html. The mode is overridable per site.
Audio / TTS: mentions are never spoken
A real hazard for the bake path — handled by an invariant. The TTS reading is generated from the
post's markdown body (registerTTS(slug, html, …), where html is the converted content)
before the theme runs. Mentions are theme chrome rendered as a sibling of {{ content }}
— like the author card, downloads and tags, none of which are spoken. So:
Invariant: mentions render outside the content /
e-contentbody the TTS extractor reads. Themes must keep the mentions block a sibling of the content element, never inside it.
This means baked mentions are excluded from audio for free (the TTS source predates theming); the guard just stops a theme from accidentally nesting them into the content element.
Sending
colophon webmention send works off the built output (or the deployed URLs):
- Scan each published post's HTML for outbound links (
http(s)://to other origins). - For each target, discover its endpoint — an HTTP
Link: rel="webmention"header, else a<link rel="webmention">/<a rel="webmention">in the body. - POST
source=<post URL>&target=<their URL>to the endpoint. - Maintain a per-post sent-cache of the link set last sent. On re-run, send only new targets — and re-send to dropped targets so that if you edit a post to remove a link, the receiver re-checks, sees the link gone, and removes its mention (the spec's update/delete path).
The sent-cache is what makes "we changed the context after sending" correct rather than silent.
The mutability question (and how it plays with existing choices)
Three cases, by what changes:
- Post content changes → fine. Received mentions target the URL, not the prose; edits are normal. Senders replied to the address.
- Post URL/slug changes → the real gotcha. webmention.io holds mentions under the old
URL; the new page finds none; inbound links rot. Mitigations: (1) keep slugs stable
(link-rot discipline); (2) once the backlog
aliases:/ redirects feature lands,fetchqueries the old keys too and the old URL 302s to the new; (3)doctorwarns when a post that has cached mentions changes slug. This makesaliases:a soft prerequisite for robust webmention and is the main cross-feature dependency. - A sender deletes/edits a mention, or we drop a sent link → reconciled by re-running:
fetchalways returns the current set (deletions vanish);send's sent-cache re-pings dropped targets. JS display is always-current; baked display reconciles on the next fetch+build.
Spam/moderation: inbound mentions can be junk — handled by a declarative, committed blocklist plus an optional moderation skill; see Moderation.
Moderation: a distilled, committed blocklist
Inbound mentions are third-party content, so the author needs a way to drop spam/abuse — and it has
to survive fetch's full regenerate (editing the generated _mentions/ JSON is pointless; the
next fetch overwrites it). So moderation is declarative and committed, applied as a filter step
over the normalised list, and reused by every display mode.
The blocklist is rules over normalised mention attributes, glob-matched. Matchable fields:
domain, url, author.name, author.url, content, type. A bare string is shorthand for
domain/author.url; the structured form targets a field:
# .colophon/webmention-block.yml (committed)
- "*.spam.example" # shorthand: domain/author.url glob
- author.url: "https://troll.example/*"
- content: "*free crypto*"
- domain: "*.cn.example"
- One filter pipeline, two execution sites. Applied server-side in
fetch(full power) forasset/baked; forlivethe distilled blocklist is shipped to the client and the same glob rules run inmentions.js(it's spam-hiding, not a secret). Glob rules run in both places; semantic rules (below) are server-only. - Semantic moderation (future). When the semantic subsystem lands, a rule kind scores a
mention's
contentagainst a concept ("spam"/"abuse") via embeddings + a threshold, slotting into the same server-side pipeline. Not available client-side, so semantic-moderated sites useasset/baked. (Ties into [decisionsearch] — the shared embedding subsystem.) - A
moderate-mentionsskill (an agent skill alongside the other authoring skills) scans the current mention set, flags likely spam/abuse, and either auto-filters or presents a decision list for the author to confirm. Crucially it distills confirmed cases into generalised glob (and later semantic) rules appended to the blocklist — so the list stays small and effective rather than an ever-growing pile of individual URLs. - Receiver-side delete (e.g. webmention.io's dashboard) remains the quick one-off: delete there,
the next
fetchwon't return it. The committed blocklist is the version-controlled, reproducible path; per-mention approval queues are deferred.
Separate publish pipeline
The _mentions/ assets are published independently of the content build/deploy, so you can
update one without the other. colophon already partitions output by path at publish time (the
router sends _search/** to the R2 publisher while content goes to Pages); the same machinery
publishes only the _mentions/** prefix.
colophon webmention publish --env production # fetch + write _mentions/ + push ONLY that prefix to R2
So the two cadences are decoupled:
- Content pipeline (
build→publish): ships HTML + JS placeholders. Never depends on the mentions cache; a fresh runner is fine. - Webmention pipeline (
webmention publish, e.g. hourly cron): refreshes_mentions/on R2. The JS path picks it up in the browser with no site rebuild.
This is the strongest reason JS is the default render path — it's the only one that benefits from
the decoupling (a baked theme still needs a content rebuild to reflect new mentions, so baking
suits low-frequency / no-JS sites). Implementation reuses the per-publisher routing
(router.Owns/Keep) plus a publish that only materialises the _mentions/ tree.
Search: mentions as down-ranked results (optional, asset mode)
Replies carry real text ("someone said X about post Y"), so indexing them into the site's lexical search is sensible — and plausible, because we already hold the normalised list. Design:
- Only
assetmode. A static index needs the data at build time;livehas none. So search-indexed mentions are anasset-mode /fetch-driven feature (off inlive/disabled). - Only text-bearing kinds. Index
reply/mention(they havecontent); skiplike/repost(nothing to match). Each indexed doc carriescontent,author, the target post it lives under, andkind: mention. - A separate, down-ranked shard. Rather than mixing mention docs into the content index (which
would churn it on every mentions refresh), emit a second search shard for mentions, merged
client-side. This keeps it on the webmention publish cadence (consistent with
_mentions/decoupling) and lets the UI treat it differently: a rank penalty plus grouping so mention hits sort below content hits — or render in a distinct "Mentions" group appended to the bottom of the results list. Configurable; default down-ranked-and-grouped. - A hit links on-site: to the post's responses anchor (keeps the reader on the site), citing the mention's source URL. Freshness is as-of-last index refresh.
- Blocklist-gated. Mentions are filtered through the same moderation pipeline before indexing — blocked mentions are neither displayed nor searchable.
This reuses the internal pure-Go BM25 index (decision search): add a kind field + a rank weight,
emit the extra shard, and teach the search UI to group/penalise kind: mention. Marked a later
enhancement (after Tier 2 display), gated by an explicit search_index: true under webmention.
Fit with existing design
| Concern | Reused pattern |
|---|---|
| Separate JSON asset, R2-routed, cross-origin | _search/ index + publish --create CORS |
| Browser fetch + render, no-JS fallback | search-ui.js / player.js progressive enhancement |
Template data exposure (mentions/mentions_html) |
attachments/attachments_html |
| Per-path publish partitioning | the router's _search/** → R2 split (router.Owns/Keep) |
| Read token from environment, not config | all secrets via {env:…} |
| Config wiring | federation.indieweb.webmention.receiver (already present, unread) |
| Parsing mention authors | the mf2 h-card/h-entry just shipped |
Config
sites:
- id: main
federation:
indieweb:
webmention:
receiver: https://webmention.io/blog.example.com/webmention # the rel=webmention endpoint
# token read from env, e.g. WEBMENTION_IO_TOKEN, never written here
driver: jf2 # reader driver (read API + client descriptor); default jf2
display:
mode: asset # live | asset | disabled (see Display)
# blocklist lives in .colophon/webmention-block.yml (committed), not inline
mode: live needs nothing else; asset uses the webmention fetch/publish pipeline (and can be
baked at build by the theme); disabled ships nothing. Per page: webmentions: false opts a post
out when the mode is active.
<link rel="webmention" href="{{ receiver }}"> is emitted in every page <head> when a receiver
is configured (the discovery tag senders look for).
Key decisions
- Separate
webmentioncommand (sendafter publish,fetchbefore build,publishon its own cadence) — not folded intobuild/publish, because of the live-URL ordering constraint and the decoupled-refresh goal. - Received data is a separate
_mentions/asset (R2-routed, CORS via--create), never mixed into content — mirrors_search/. The cache is not reproducible (external state); builds are graceful when it's empty, and freshness comes fromfetch/publish, not the repo. - One JSON per post, normalised (
type/author/url/content), not raw receiver payload. - Display is a per-site
mode—live|asset|disabled(there is no separate baked mode).live= browser→receiver direct (most realtime, client-side glob blocklist, no build-time data so no count/bake, no no-JS);asset= browser→our R2 asset refreshed by a cronpublish(full moderation, self-hosted) and the synced list is exposed at build so a theme may bake it (baking is a theme capability withinasset, not a mode);disabled= nothing ships, zero counts, page toggle ignored. Per-page opt-out viawebmentions: false. The engine exposesmentions/mentions_html/has_mentions/mentions_src/mentions_enabled; the theme renders.livemode's fetch JS is parameterised by the reader driver's client descriptor. 4b. Moderation is a declarative, committed blocklist of glob rules over mention attributes (.colophon/webmention-block.yml), re-applied everyfetch(and shipped to the client inlivemode). Future semantic rules run server-side; amoderate-mentionsskill distills confirmed spam into small, general rules. Survives full-regenerate because it's declarative. - TTS invariant: mentions live outside the content body the speech extractor reads, so they're never spoken — true for both paths.
- Separate publish pipeline:
webmention publishpushes only_mentions/to the store, so mentions and content update independently (reuses per-publisher path routing). - Sent-cache drives correct re-send on link changes;
aliases:is the soft dependency for surviving URL changes. - webmention.io as the receiver (no self-hosted endpoint) — per PLAN §10/§14.
Acceptance criteria
colophon webmention senddiscovers endpoints and POSTs for outbound links; re-run is a no-op unless links changed; dropped links trigger a delete-style re-send.colophon webmention fetchwrites normalised JSON into the local cache;webmention publishpushes only_mentions/**to the store, independent of a content deploy.- A build with an empty cache succeeds and shows no mentions (graceful); a build with the cache
present exposes
mentions/mentions_htmlto templates and emits_mentions/assets. - A mention deleted on webmention.io disappears after the next
fetch+publish; a post that drops to zero mentions has its_mentions/<post>.jsonremoved and the orphan pruned from the store (JS path 404s → renders nothing). Pruning is scoped to the_mentions/prefix. - A JS-enhanced theme shows likes/reposts/replies; with JS off the post is unaffected;
minimalbakes them statically. Mentions never appear in a post's TTS audio. display.mode: liverenders mentions with nofetch/publish/rebuild (browser → receiver), honouring the shipped glob blocklist client-side, with no build-time count;assetrenders from the published_mentions/(and exposes the synced list so a theme may bake it at build);disabledships nothing (zero counts,webmentions:page setting ignored).webmentions: falseopts a single post out when the mode is active.- A blocklisted mention (glob over domain/url/author/content) never appears in any mode; the
blocklist survives a full
fetchregenerate (it's committed, not edited into the export). - Secrets come only from the environment; a site with no
webmentionconfig emits nothing.
Files to create (when built)
internal/webmention/— endpoint discovery + sender (sent-cache) + reader driver (server fetch + client descriptor) + normaliser + the blocklist filter pipeline.internal/cli/webmention.go— thewebmention {send,fetch,publish}command group;publishsupports the incremental (changed-mention-posts-only) re-render forbaked.- build: expose
mentions/mentions_html/has_mentions/mentions_srcto templates, emit_mentions/assets + the<link rel="webmention">head tag; inlivemode emit the driver client descriptor + the distilled blocklist for client-side filtering. internal/render/themes/*/…— adata-mentionsplaceholder + engine-emittedmentions.js(parameterised by the driver descriptor; applies glob blocklist inlive) and a pongo-baked block (text themes), kept outside the content/e-contentelement.contrib/skills/(+ wiring) — amoderate-mentionsskill that flags spam/abuse and distills confirmed cases into blocklist rules.
Out of scope (future)
- Self-hosted webmention receiver (PLAN §14 leaves this open; webmention.io is the v1 choice).
- Rich moderation UI / per-mention approval.
- Sending as specific post types (replies/likes from colophon itself) — colophon publishes articles; it links, it doesn't (yet) author reply-posts.
- Fediverse/Bridgy Fed backfeed — a later layer that reuses this send/receive + mf2 substrate.
Generated from docs/design/webmention.md — edit it there.